From 564ce5f3b898503fa38d58871368d31d771ba85f Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:11:46 -0700 Subject: [PATCH 1/8] feat: extend engineer mcp --- .github/workflows/ci.yml | 6 + README.md | 42 +- data/references.json | 7 + data/sections.json | 652 ++++++++++++++++++++++++++++++++ docs/integration.md | 2 +- docs/mcp-tools.md | 33 +- examples/demo.ts | 24 +- package-lock.json | 4 +- package.json | 3 +- src/assets.ts | 19 + src/context.ts | 54 +++ src/db/database.ts | 41 +- src/handlers.ts | 150 +++++++- src/index.ts | 16 +- src/schemas.ts | 10 + src/server.ts | 6 + src/units/dimensions.ts | 2 + src/units/registry.ts | 11 + src/version.ts | 2 +- tests/standard-sections.test.ts | 188 +++++++++ tests/tools.test.ts | 4 +- tests/units.test.ts | 46 +++ 22 files changed, 1299 insertions(+), 23 deletions(-) create mode 100644 data/sections.json create mode 100644 tests/standard-sections.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2821e15..50a783e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,12 @@ jobs: - name: Build run: npm run build + - name: Verify the CLI contract + run: | + node dist/index.js --version | grep -q "^engineer-mcp" + node dist/index.js --list | grep -q "beam_bending" + node dist/index.js --list | grep -q "section_catalog" + - name: Verify the demo runs run: npm run demo diff --git a/README.md b/README.md index e94b177..b352615 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ It gives coding agents verified answers for beams, bolts, springs, shafts, beari Every result shows the formula, the method, and the source. ## What it provides - Use Engineer MCP inside an AI coding agent. The agent calls a tool and receives a complete engineering answer. The answer includes numbers, units, assumptions, and citations. @@ -24,6 +23,7 @@ The release covers these domains: - Bearing rating life to ISO 281. - von Mises equivalent stress. - Cross-section properties. +- Standard steel section catalog to EN 10365. - Dimension-safe unit conversion. - Material property lookup. @@ -55,6 +55,7 @@ Warnings surface when a method uses an approximation. | `von_mises` | Equivalent stress and yield safety factor. | | `unit_convert` | Conversion between compatible units. | | `material_lookup` | Curated mechanical properties of materials. | +| `section_catalog` | Published IPE, HEA, HEB, and UPN steel sections. | See [docs/mcp-tools.md](docs/mcp-tools.md) for the full reference. @@ -83,7 +84,7 @@ Key directories: | `src/units/` | Dimension-safe unit conversion. | | `src/db/` | SQLite schema and seeding. | | `src/handlers.ts` | Tool orchestration and result envelopes. | -| `data/` | Material, fastener, and reference data. | +| `data/` | Material, fastener, section, and reference data. | ## Quick start @@ -154,6 +155,34 @@ Error: Category mismatch: N·m is torque, J is energy. Use a unit of the same quantity. ``` +A call to `section_catalog` for the HEB series returns the published sections: + +```text +Rows: + - HEB 100 | h 100 mm | I 450 cm4 | W 89.9 cm3 | 20.4 kg/m + - HEB 120 | h 120 mm | I 864 cm4 | W 144 cm3 | 26.7 kg/m + - HEB 140 | h 140 mm | I 1509 cm4 | W 216 cm3 | 33.7 kg/m + - HEB 160 | h 160 mm | I 2492 cm4 | W 311 cm3 | 42.6 kg/m + +Method: Standard section catalog lookup +References: + - EN 10365 - Hot rolled steel channels, I and H sections - Dimensions and masses +``` + +Pass a catalog designation to `beam_bending` to use the published section properties: + +```text +Maximum bending moment 15 kN·m +Maximum bending stress 26.93 MPa +Maximum deflection 0.6411 mm +Bending safety factor 13.18 + +References: + - Roark's Formulas for Stress and Strain (Eighth edition, 2011) + - Mechanics of Materials (Euler-Bernoulli beam theory) + - EN 10365 - Hot rolled steel channels, I and H sections - Dimensions and masses +``` + ## Development | Command | Purpose | @@ -169,9 +198,10 @@ Use a unit of the same quantity. The test suite is deterministic and offline. It covers the engines, the unit layer, the database, and the tools. -- 98 tests across 10 files. +- 116 tests across 11 files. - All tests pass on Node 22 and Node 24. - The CI workflow runs typecheck, tests, build, demo, and a package check. +- The CI workflow verifies the CLI contract over standard output. Run `npm test` to reproduce the results. @@ -184,6 +214,8 @@ Run `npm test` to reproduce the results. - The critical speed is a first-mode approximation. - The spring design covers static round-wire springs only. It does not estimate fatigue life for cyclic loads. +- The section catalog covers common IPE, HEA, HEB, and UPN sizes. + It does not include every size in the standard. - The built-in SQLite module of Node.js is still experimental. Check the cited sources for exact values. @@ -197,6 +229,9 @@ Each release stays useful on its own. - Helical compression spring design. The `spring_design` tool reports the spring rate, the shear stress, and the safety factor. +- Standard steel section catalog. + The `section_catalog` tool searches the published IPE, HEA, HEB, and UPN series. + The `beam_bending` and `section_properties` tools accept a catalog designation. ### Remaining @@ -204,7 +239,6 @@ Each release stays useful on its own. - Add press-fit and interference-fit calculators. - Add more unit categories, including viscosity and thermal conductivity. - Add HTTP transport. -- Add a catalog of ISO and DIN standard sections. See [docs/integration.md](docs/integration.md) for the EngineerKit plan. diff --git a/data/references.json b/data/references.json index b85dfc0..b241946 100644 --- a/data/references.json +++ b/data/references.json @@ -54,5 +54,12 @@ "edition": "Any standard university text", "section": "Critical speed of a uniform simply supported shaft", "note": "Gives the first lateral critical speed for a uniform shaft." + }, + "en-10365": { + "title": "EN 10365 - Hot rolled steel channels, I and H sections - Dimensions and masses", + "source": "European Committee for Standardization", + "edition": "EN 10365:2017", + "section": "Dimensions, masses, and section properties of rolled sections", + "note": "Nominal dimensions and section properties for IPE, HEA, HEB, and UPN series." } } diff --git a/data/sections.json b/data/sections.json new file mode 100644 index 0000000..713a5fa --- /dev/null +++ b/data/sections.json @@ -0,0 +1,652 @@ +[ + { + "designation": "IPE 80", + "series": "IPE", + "standard": "EN 10365", + "heightMm": 80, + "flangeWidthMm": 46, + "webThicknessMm": 3.8, + "flangeThicknessMm": 5.2, + "areaCm2": 7.64, + "massPerMetreKgM": 6.0, + "secondMomentCm4": 80.1, + "sectionModulusCm3": 20.0 + }, + { + "designation": "IPE 100", + "series": "IPE", + "standard": "EN 10365", + "heightMm": 100, + "flangeWidthMm": 55, + "webThicknessMm": 4.1, + "flangeThicknessMm": 5.7, + "areaCm2": 10.3, + "massPerMetreKgM": 8.1, + "secondMomentCm4": 171, + "sectionModulusCm3": 34.2 + }, + { + "designation": "IPE 120", + "series": "IPE", + "standard": "EN 10365", + "heightMm": 120, + "flangeWidthMm": 64, + "webThicknessMm": 4.4, + "flangeThicknessMm": 6.3, + "areaCm2": 13.2, + "massPerMetreKgM": 10.4, + "secondMomentCm4": 318, + "sectionModulusCm3": 53.0 + }, + { + "designation": "IPE 140", + "series": "IPE", + "standard": "EN 10365", + "heightMm": 140, + "flangeWidthMm": 73, + "webThicknessMm": 4.7, + "flangeThicknessMm": 6.9, + "areaCm2": 16.4, + "massPerMetreKgM": 12.9, + "secondMomentCm4": 541, + "sectionModulusCm3": 77.3 + }, + { + "designation": "IPE 160", + "series": "IPE", + "standard": "EN 10365", + "heightMm": 160, + "flangeWidthMm": 82, + "webThicknessMm": 5.0, + "flangeThicknessMm": 7.4, + "areaCm2": 20.1, + "massPerMetreKgM": 15.8, + "secondMomentCm4": 869, + "sectionModulusCm3": 109 + }, + { + "designation": "IPE 180", + "series": "IPE", + "standard": "EN 10365", + "heightMm": 180, + "flangeWidthMm": 91, + "webThicknessMm": 5.3, + "flangeThicknessMm": 8.0, + "areaCm2": 23.9, + "massPerMetreKgM": 18.8, + "secondMomentCm4": 1317, + "sectionModulusCm3": 146 + }, + { + "designation": "IPE 200", + "series": "IPE", + "standard": "EN 10365", + "heightMm": 200, + "flangeWidthMm": 100, + "webThicknessMm": 5.6, + "flangeThicknessMm": 8.5, + "areaCm2": 28.5, + "massPerMetreKgM": 22.4, + "secondMomentCm4": 1943, + "sectionModulusCm3": 194 + }, + { + "designation": "IPE 220", + "series": "IPE", + "standard": "EN 10365", + "heightMm": 220, + "flangeWidthMm": 110, + "webThicknessMm": 5.9, + "flangeThicknessMm": 9.2, + "areaCm2": 33.4, + "massPerMetreKgM": 26.2, + "secondMomentCm4": 2772, + "sectionModulusCm3": 252 + }, + { + "designation": "IPE 240", + "series": "IPE", + "standard": "EN 10365", + "heightMm": 240, + "flangeWidthMm": 120, + "webThicknessMm": 6.2, + "flangeThicknessMm": 9.8, + "areaCm2": 39.1, + "massPerMetreKgM": 30.7, + "secondMomentCm4": 3892, + "sectionModulusCm3": 324 + }, + { + "designation": "IPE 270", + "series": "IPE", + "standard": "EN 10365", + "heightMm": 270, + "flangeWidthMm": 135, + "webThicknessMm": 6.6, + "flangeThicknessMm": 10.2, + "areaCm2": 45.9, + "massPerMetreKgM": 36.1, + "secondMomentCm4": 5790, + "sectionModulusCm3": 429 + }, + { + "designation": "IPE 300", + "series": "IPE", + "standard": "EN 10365", + "heightMm": 300, + "flangeWidthMm": 150, + "webThicknessMm": 7.1, + "flangeThicknessMm": 10.7, + "areaCm2": 53.8, + "massPerMetreKgM": 42.2, + "secondMomentCm4": 8356, + "sectionModulusCm3": 557 + }, + { + "designation": "IPE 330", + "series": "IPE", + "standard": "EN 10365", + "heightMm": 330, + "flangeWidthMm": 160, + "webThicknessMm": 7.5, + "flangeThicknessMm": 11.5, + "areaCm2": 62.6, + "massPerMetreKgM": 49.1, + "secondMomentCm4": 11770, + "sectionModulusCm3": 713 + }, + { + "designation": "IPE 360", + "series": "IPE", + "standard": "EN 10365", + "heightMm": 360, + "flangeWidthMm": 170, + "webThicknessMm": 8.0, + "flangeThicknessMm": 12.7, + "areaCm2": 72.7, + "massPerMetreKgM": 57.1, + "secondMomentCm4": 16270, + "sectionModulusCm3": 904 + }, + { + "designation": "IPE 400", + "series": "IPE", + "standard": "EN 10365", + "heightMm": 400, + "flangeWidthMm": 180, + "webThicknessMm": 8.6, + "flangeThicknessMm": 13.5, + "areaCm2": 84.5, + "massPerMetreKgM": 66.3, + "secondMomentCm4": 23130, + "sectionModulusCm3": 1160 + }, + { + "designation": "IPE 450", + "series": "IPE", + "standard": "EN 10365", + "heightMm": 450, + "flangeWidthMm": 190, + "webThicknessMm": 9.4, + "flangeThicknessMm": 14.6, + "areaCm2": 98.8, + "massPerMetreKgM": 77.6, + "secondMomentCm4": 33740, + "sectionModulusCm3": 1500 + }, + { + "designation": "IPE 500", + "series": "IPE", + "standard": "EN 10365", + "heightMm": 500, + "flangeWidthMm": 200, + "webThicknessMm": 10.2, + "flangeThicknessMm": 16.0, + "areaCm2": 115.5, + "massPerMetreKgM": 90.7, + "secondMomentCm4": 48200, + "sectionModulusCm3": 1930 + }, + { + "designation": "HEA 100", + "series": "HEA", + "standard": "EN 10365", + "heightMm": 96, + "flangeWidthMm": 100, + "webThicknessMm": 5.0, + "flangeThicknessMm": 8.0, + "areaCm2": 21.2, + "massPerMetreKgM": 16.7, + "secondMomentCm4": 349, + "sectionModulusCm3": 72.7 + }, + { + "designation": "HEA 120", + "series": "HEA", + "standard": "EN 10365", + "heightMm": 114, + "flangeWidthMm": 120, + "webThicknessMm": 5.0, + "flangeThicknessMm": 8.0, + "areaCm2": 25.3, + "massPerMetreKgM": 19.9, + "secondMomentCm4": 606, + "sectionModulusCm3": 106 + }, + { + "designation": "HEA 140", + "series": "HEA", + "standard": "EN 10365", + "heightMm": 133, + "flangeWidthMm": 140, + "webThicknessMm": 5.5, + "flangeThicknessMm": 8.5, + "areaCm2": 31.4, + "massPerMetreKgM": 24.7, + "secondMomentCm4": 1033, + "sectionModulusCm3": 155 + }, + { + "designation": "HEA 160", + "series": "HEA", + "standard": "EN 10365", + "heightMm": 152, + "flangeWidthMm": 160, + "webThicknessMm": 6.0, + "flangeThicknessMm": 9.0, + "areaCm2": 38.8, + "massPerMetreKgM": 30.4, + "secondMomentCm4": 1673, + "sectionModulusCm3": 220 + }, + { + "designation": "HEA 180", + "series": "HEA", + "standard": "EN 10365", + "heightMm": 171, + "flangeWidthMm": 180, + "webThicknessMm": 6.0, + "flangeThicknessMm": 9.5, + "areaCm2": 45.3, + "massPerMetreKgM": 35.5, + "secondMomentCm4": 2510, + "sectionModulusCm3": 294 + }, + { + "designation": "HEA 200", + "series": "HEA", + "standard": "EN 10365", + "heightMm": 190, + "flangeWidthMm": 200, + "webThicknessMm": 6.5, + "flangeThicknessMm": 10.0, + "areaCm2": 53.8, + "massPerMetreKgM": 42.3, + "secondMomentCm4": 3692, + "sectionModulusCm3": 389 + }, + { + "designation": "HEA 220", + "series": "HEA", + "standard": "EN 10365", + "heightMm": 210, + "flangeWidthMm": 220, + "webThicknessMm": 7.0, + "flangeThicknessMm": 11.0, + "areaCm2": 64.3, + "massPerMetreKgM": 50.5, + "secondMomentCm4": 5410, + "sectionModulusCm3": 515 + }, + { + "designation": "HEA 240", + "series": "HEA", + "standard": "EN 10365", + "heightMm": 230, + "flangeWidthMm": 240, + "webThicknessMm": 7.5, + "flangeThicknessMm": 12.0, + "areaCm2": 76.8, + "massPerMetreKgM": 60.3, + "secondMomentCm4": 7763, + "sectionModulusCm3": 675 + }, + { + "designation": "HEA 260", + "series": "HEA", + "standard": "EN 10365", + "heightMm": 250, + "flangeWidthMm": 260, + "webThicknessMm": 7.5, + "flangeThicknessMm": 12.5, + "areaCm2": 86.8, + "massPerMetreKgM": 68.2, + "secondMomentCm4": 10450, + "sectionModulusCm3": 836 + }, + { + "designation": "HEA 280", + "series": "HEA", + "standard": "EN 10365", + "heightMm": 270, + "flangeWidthMm": 280, + "webThicknessMm": 8.0, + "flangeThicknessMm": 13.0, + "areaCm2": 97.3, + "massPerMetreKgM": 76.4, + "secondMomentCm4": 13670, + "sectionModulusCm3": 1010 + }, + { + "designation": "HEA 300", + "series": "HEA", + "standard": "EN 10365", + "heightMm": 290, + "flangeWidthMm": 300, + "webThicknessMm": 8.5, + "flangeThicknessMm": 14.0, + "areaCm2": 112.5, + "massPerMetreKgM": 88.3, + "secondMomentCm4": 18260, + "sectionModulusCm3": 1260 + }, + { + "designation": "HEB 100", + "series": "HEB", + "standard": "EN 10365", + "heightMm": 100, + "flangeWidthMm": 100, + "webThicknessMm": 6.0, + "flangeThicknessMm": 10.0, + "areaCm2": 26.0, + "massPerMetreKgM": 20.4, + "secondMomentCm4": 450, + "sectionModulusCm3": 89.9 + }, + { + "designation": "HEB 120", + "series": "HEB", + "standard": "EN 10365", + "heightMm": 120, + "flangeWidthMm": 120, + "webThicknessMm": 6.5, + "flangeThicknessMm": 11.0, + "areaCm2": 34.0, + "massPerMetreKgM": 26.7, + "secondMomentCm4": 864, + "sectionModulusCm3": 144 + }, + { + "designation": "HEB 140", + "series": "HEB", + "standard": "EN 10365", + "heightMm": 140, + "flangeWidthMm": 140, + "webThicknessMm": 7.0, + "flangeThicknessMm": 12.0, + "areaCm2": 43.0, + "massPerMetreKgM": 33.7, + "secondMomentCm4": 1509, + "sectionModulusCm3": 216 + }, + { + "designation": "HEB 160", + "series": "HEB", + "standard": "EN 10365", + "heightMm": 160, + "flangeWidthMm": 160, + "webThicknessMm": 8.0, + "flangeThicknessMm": 13.0, + "areaCm2": 54.3, + "massPerMetreKgM": 42.6, + "secondMomentCm4": 2492, + "sectionModulusCm3": 311 + }, + { + "designation": "HEB 180", + "series": "HEB", + "standard": "EN 10365", + "heightMm": 180, + "flangeWidthMm": 180, + "webThicknessMm": 8.5, + "flangeThicknessMm": 14.0, + "areaCm2": 65.3, + "massPerMetreKgM": 51.2, + "secondMomentCm4": 3831, + "sectionModulusCm3": 426 + }, + { + "designation": "HEB 200", + "series": "HEB", + "standard": "EN 10365", + "heightMm": 200, + "flangeWidthMm": 200, + "webThicknessMm": 9.0, + "flangeThicknessMm": 15.0, + "areaCm2": 78.1, + "massPerMetreKgM": 61.3, + "secondMomentCm4": 5696, + "sectionModulusCm3": 570 + }, + { + "designation": "HEB 220", + "series": "HEB", + "standard": "EN 10365", + "heightMm": 220, + "flangeWidthMm": 220, + "webThicknessMm": 9.5, + "flangeThicknessMm": 16.0, + "areaCm2": 91.0, + "massPerMetreKgM": 71.5, + "secondMomentCm4": 8091, + "sectionModulusCm3": 736 + }, + { + "designation": "HEB 240", + "series": "HEB", + "standard": "EN 10365", + "heightMm": 240, + "flangeWidthMm": 240, + "webThicknessMm": 10.0, + "flangeThicknessMm": 17.0, + "areaCm2": 106.0, + "massPerMetreKgM": 83.2, + "secondMomentCm4": 11260, + "sectionModulusCm3": 938 + }, + { + "designation": "HEB 260", + "series": "HEB", + "standard": "EN 10365", + "heightMm": 260, + "flangeWidthMm": 260, + "webThicknessMm": 10.0, + "flangeThicknessMm": 17.5, + "areaCm2": 118.4, + "massPerMetreKgM": 93.0, + "secondMomentCm4": 14920, + "sectionModulusCm3": 1150 + }, + { + "designation": "HEB 280", + "series": "HEB", + "standard": "EN 10365", + "heightMm": 280, + "flangeWidthMm": 280, + "webThicknessMm": 10.5, + "flangeThicknessMm": 18.0, + "areaCm2": 131.4, + "massPerMetreKgM": 103, + "secondMomentCm4": 19270, + "sectionModulusCm3": 1380 + }, + { + "designation": "HEB 300", + "series": "HEB", + "standard": "EN 10365", + "heightMm": 300, + "flangeWidthMm": 300, + "webThicknessMm": 11.0, + "flangeThicknessMm": 19.0, + "areaCm2": 149.1, + "massPerMetreKgM": 117, + "secondMomentCm4": 25170, + "sectionModulusCm3": 1680 + }, + { + "designation": "UPN 80", + "series": "UPN", + "standard": "EN 10365", + "heightMm": 80, + "flangeWidthMm": 45, + "webThicknessMm": 6.0, + "flangeThicknessMm": 8.0, + "areaCm2": 11.0, + "massPerMetreKgM": 8.64, + "secondMomentCm4": 106, + "sectionModulusCm3": 26.5 + }, + { + "designation": "UPN 100", + "series": "UPN", + "standard": "EN 10365", + "heightMm": 100, + "flangeWidthMm": 50, + "webThicknessMm": 6.0, + "flangeThicknessMm": 8.5, + "areaCm2": 13.5, + "massPerMetreKgM": 10.6, + "secondMomentCm4": 206, + "sectionModulusCm3": 41.2 + }, + { + "designation": "UPN 120", + "series": "UPN", + "standard": "EN 10365", + "heightMm": 120, + "flangeWidthMm": 55, + "webThicknessMm": 7.0, + "flangeThicknessMm": 9.0, + "areaCm2": 17.0, + "massPerMetreKgM": 13.4, + "secondMomentCm4": 364, + "sectionModulusCm3": 60.7 + }, + { + "designation": "UPN 140", + "series": "UPN", + "standard": "EN 10365", + "heightMm": 140, + "flangeWidthMm": 60, + "webThicknessMm": 7.0, + "flangeThicknessMm": 10.0, + "areaCm2": 20.4, + "massPerMetreKgM": 16.0, + "secondMomentCm4": 605, + "sectionModulusCm3": 86.4 + }, + { + "designation": "UPN 160", + "series": "UPN", + "standard": "EN 10365", + "heightMm": 160, + "flangeWidthMm": 65, + "webThicknessMm": 7.5, + "flangeThicknessMm": 10.5, + "areaCm2": 24.0, + "massPerMetreKgM": 18.8, + "secondMomentCm4": 925, + "sectionModulusCm3": 116 + }, + { + "designation": "UPN 180", + "series": "UPN", + "standard": "EN 10365", + "heightMm": 180, + "flangeWidthMm": 70, + "webThicknessMm": 8.0, + "flangeThicknessMm": 11.0, + "areaCm2": 28.0, + "massPerMetreKgM": 22.0, + "secondMomentCm4": 1350, + "sectionModulusCm3": 150 + }, + { + "designation": "UPN 200", + "series": "UPN", + "standard": "EN 10365", + "heightMm": 200, + "flangeWidthMm": 75, + "webThicknessMm": 8.5, + "flangeThicknessMm": 11.5, + "areaCm2": 32.2, + "massPerMetreKgM": 25.3, + "secondMomentCm4": 1910, + "sectionModulusCm3": 191 + }, + { + "designation": "UPN 220", + "series": "UPN", + "standard": "EN 10365", + "heightMm": 220, + "flangeWidthMm": 80, + "webThicknessMm": 9.0, + "flangeThicknessMm": 12.5, + "areaCm2": 37.4, + "massPerMetreKgM": 29.4, + "secondMomentCm4": 2690, + "sectionModulusCm3": 245 + }, + { + "designation": "UPN 240", + "series": "UPN", + "standard": "EN 10365", + "heightMm": 240, + "flangeWidthMm": 85, + "webThicknessMm": 9.5, + "flangeThicknessMm": 13.0, + "areaCm2": 42.3, + "massPerMetreKgM": 33.2, + "secondMomentCm4": 3600, + "sectionModulusCm3": 300 + }, + { + "designation": "UPN 260", + "series": "UPN", + "standard": "EN 10365", + "heightMm": 260, + "flangeWidthMm": 90, + "webThicknessMm": 10.0, + "flangeThicknessMm": 14.0, + "areaCm2": 48.3, + "massPerMetreKgM": 37.9, + "secondMomentCm4": 4820, + "sectionModulusCm3": 371 + }, + { + "designation": "UPN 280", + "series": "UPN", + "standard": "EN 10365", + "heightMm": 280, + "flangeWidthMm": 95, + "webThicknessMm": 10.0, + "flangeThicknessMm": 15.0, + "areaCm2": 53.3, + "massPerMetreKgM": 41.8, + "secondMomentCm4": 6280, + "sectionModulusCm3": 448 + }, + { + "designation": "UPN 300", + "series": "UPN", + "standard": "EN 10365", + "heightMm": 300, + "flangeWidthMm": 100, + "webThicknessMm": 10.0, + "flangeThicknessMm": 16.0, + "areaCm2": 58.8, + "massPerMetreKgM": 46.2, + "secondMomentCm4": 8030, + "sectionModulusCm3": 535 + } +] diff --git a/docs/integration.md b/docs/integration.md index 042b7d4..58e8272 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -41,6 +41,7 @@ Engineer MCP grows in independent releases. Each release stays useful on its own ### Complete - Helical compression spring design. The `spring_design` tool computes the spring rate, the shear stress, and the safety factor. +- Standard section catalog. The `section_catalog` tool searches published IPE, HEA, HEB, and UPN sections. The `beam_bending` and `section_properties` tools accept a standard designation. ### Remaining @@ -48,6 +49,5 @@ Engineer MCP grows in independent releases. Each release stays useful on its own - Add press-fit and interference-fit calculators. - Add more unit categories, including viscosity and thermal conductivity. - Add HTTP transport in addition to stdio. -- Add a catalog of ISO and DIN standard sections. Keep each release small and deterministic. Run the full test suite before release. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index d38b8c9..c6914f9 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -31,7 +31,7 @@ Inputs: - `material`: material name from the database. - `elasticModulus`: Young's modulus in pascals. - `yieldStrength`: tensile yield strength in pascals. -- `section`: cross-section shape and dimensions in metres. +- `section`: cross-section shape and dimensions in metres. Use `{ "shape": "standard", "designation": "IPE 300" }` to use a catalog section. - `secondMomentOfArea` and `sectionModulus`: use these when you have no section. - `outputUnits`: optional unit overrides. @@ -63,6 +63,37 @@ Supported shapes: - `hollow_circle` with `outerDiameter` and `innerDiameter`. - `i_beam` with `height`, `flangeWidth`, `flangeThickness`, and `webThickness`. - `box` with `width`, `height`, and `thickness`. +- `standard` with `designation`. Use a catalog designation such as `IPE 300`. + +The `standard` shape returns the published values from the section catalog. It uses the strong axis for the second moment of area and the section modulus. + +Example: + +```json +{ + "section": { "shape": "standard", "designation": "IPE 300" } +} +``` + +## section_catalog + +Search the catalog of standard rolled steel sections. + +Inputs: + +- `query`: a designation, series, or standard to match. +- `limit`: the maximum number of rows. The default is 10. + +The tool returns the published dimensions, mass, second moment of area, and section modulus of each match. Use it to find a designation, then pass that designation to `beam_bending` or `section_properties`. + +Example: + +```json +{ + "query": "HEB", + "limit": 5 +} +``` ## bolt_strength diff --git a/examples/demo.ts b/examples/demo.ts index 2013998..cc0b3ce 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -34,7 +34,13 @@ function show(name: string, handler: Handler, input: Record): v if (response.rows && response.rows.length > 0) { console.log("Rows:"); for (const row of response.rows) { - console.log(` - ${String(row.name)} | yield ${row.yieldStrengthMPa} MPa | E ${row.elasticModulusGPa} GPa | density ${row.densityKgM3} kg/m3`); + if (row.designation) { + console.log( + ` - ${String(row.designation)} | h ${row.heightMm} mm | I ${row.secondMomentCm4} cm4 | W ${row.sectionModulusCm3} cm3 | ${row.massPerMetreKgM} kg/m`, + ); + } else { + console.log(` - ${String(row.name)} | yield ${row.yieldStrengthMPa} MPa | E ${row.elasticModulusGPa} GPa | density ${row.densityKgM3} kg/m3`); + } } } @@ -76,6 +82,7 @@ type ToolHandlers = { von_mises: Handler; unit_convert: Handler; material_lookup: Handler; + section_catalog: Handler; }; const toolHandlers = handlers as ToolHandlers; @@ -91,6 +98,8 @@ const tools: NamedHandler[] = [ ["unit_convert", toolHandlers.unit_convert], ["unit_convert (torque to energy)", toolHandlers.unit_convert], ["material_lookup", toolHandlers.material_lookup], + ["section_catalog", toolHandlers.section_catalog], + ["beam_bending (IPE 300)", toolHandlers.beam_bending], ]; const inputs: Array> = [ @@ -158,6 +167,19 @@ const inputs: Array> = [ { query: "steel", }, + { + query: "IPE", + limit: 4, + }, + { + support: "simply_supported", + load: "point", + loadMagnitude: 20000, + length: 3, + material: "Structural steel S355", + section: { shape: "standard", designation: "IPE 300" }, + outputUnits: { maxBendingStress: "MPa", maxDeflection: "mm", maxBendingMoment: "kN·m" }, + }, ]; console.log("Engineer MCP - demo run"); diff --git a/package-lock.json b/package-lock.json index 5c98405..edcef02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@engineerkit/engineer-mcp", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", diff --git a/package.json b/package.json index 49eb29e..b31a786 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.2.0", + "version": "0.3.0", "description": "MCP server for verified mechanical-engineering calculations, references, and material data.", "type": "module", "license": "MIT", @@ -50,6 +50,7 @@ "unit-conversion", "sqlite", "provenance", + "steel-sections", "engineerkit" ], "scripts": { diff --git a/src/assets.ts b/src/assets.ts index ef0cd81..55e52ef 100644 --- a/src/assets.ts +++ b/src/assets.ts @@ -34,9 +34,24 @@ export type GradeSeed = { ultimateStressMPa: number; }; +export type SectionSeed = { + designation: string; + series: string; + standard: string; + heightMm: number; + flangeWidthMm: number; + webThicknessMm: number; + flangeThicknessMm: number; + areaCm2: number; + massPerMetreKgM: number; + secondMomentCm4: number; + sectionModulusCm3: number; +}; + const MATERIALS_PATH = "../data/materials.json"; const FASTENERS_PATH = "../data/fasteners.json"; const REFERENCES_PATH = "../data/references.json"; +const SECTIONS_PATH = "../data/sections.json"; export function loadMaterials(): MaterialSeed[] { return readJson(MATERIALS_PATH); @@ -50,6 +65,10 @@ export function loadReferences(): Record { return readJson>(REFERENCES_PATH); } +export function loadSections(): SectionSeed[] { + return readJson(SECTIONS_PATH); +} + export const BOLT_GRADES: GradeSeed[] = [ { propertyClass: "4.8", proofStressMPa: 310, yieldStressMPa: 340, ultimateStressMPa: 400 }, { propertyClass: "5.8", proofStressMPa: 380, yieldStressMPa: 420, ultimateStressMPa: 500 }, diff --git a/src/context.ts b/src/context.ts index ee8e758..14812e8 100644 --- a/src/context.ts +++ b/src/context.ts @@ -30,6 +30,20 @@ export type GradeRow = { ultimateStressMPa: number; }; +export type StandardSectionRow = { + designation: string; + series: string; + standard: string; + heightMm: number; + flangeWidthMm: number; + webThicknessMm: number; + flangeThicknessMm: number; + areaCm2: number; + massPerMetreKgM: number; + secondMomentCm4: number; + sectionModulusCm3: number; +}; + export type AppContext = { db: DatabaseSync; references: Map; @@ -39,6 +53,9 @@ export type AppContext = { listMaterials(): MaterialRow[]; findFastener(nominalDiameterMm: number): FastenerRow | undefined; findGrade(propertyClass: string): GradeRow | undefined; + findSection(designation: string): StandardSectionRow | undefined; + searchSections(query: string, limit?: number): StandardSectionRow[]; + listSectionSeries(): string[]; }; export function createContext(dbPath = ":memory:"): AppContext { @@ -93,6 +110,27 @@ export function createContext(dbPath = ":memory:"): AppContext { .get(propertyClass) as Record | undefined; return row ? mapGrade(row) : undefined; }, + findSection(designation) { + const row = db + .prepare("SELECT * FROM standard_sections WHERE designation = ?") + .get(designation) as Record | undefined; + return row ? mapSection(row) : undefined; + }, + searchSections(query, limit = 10) { + const like = `%${query.toLowerCase()}%`; + const rows = db + .prepare( + "SELECT * FROM standard_sections WHERE LOWER(designation) LIKE ? OR LOWER(series) LIKE ? OR LOWER(standard) LIKE ? ORDER BY series, height_mm LIMIT ?", + ) + .all(like, like, like, limit) as unknown as Array>; + return rows.map(mapSection); + }, + listSectionSeries() { + const rows = db + .prepare("SELECT DISTINCT series FROM standard_sections ORDER BY series") + .all() as unknown as Array>; + return rows.map((row) => row.series as string); + }, }; } @@ -128,3 +166,19 @@ function mapGrade(row: Record): GradeRow { ultimateStressMPa: row.ultimate_stress_mpa as number, }; } + +function mapSection(row: Record): StandardSectionRow { + return { + designation: row.designation as string, + series: row.series as string, + standard: row.standard as string, + heightMm: row.height_mm as number, + flangeWidthMm: row.flange_width_mm as number, + webThicknessMm: row.web_thickness_mm as number, + flangeThicknessMm: row.flange_thickness_mm as number, + areaCm2: row.area_cm2 as number, + massPerMetreKgM: row.mass_per_metre_kg_m as number, + secondMomentCm4: row.second_moment_cm4 as number, + sectionModulusCm3: row.section_modulus_cm3 as number, + }; +} diff --git a/src/db/database.ts b/src/db/database.ts index 70f7e29..ca41e38 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -1,5 +1,5 @@ import { DatabaseSync } from "node:sqlite"; -import { BOLT_GRADES, loadFasteners, loadMaterials, loadReferences } from "../assets.js"; +import { BOLT_GRADES, loadFasteners, loadMaterials, loadReferences, loadSections } from "../assets.js"; const SCHEMA = ` CREATE TABLE IF NOT EXISTS sources ( @@ -40,6 +40,21 @@ CREATE TABLE IF NOT EXISTS fasteners ( minor_diameter_mm REAL NOT NULL, reference_id TEXT ); + +CREATE TABLE IF NOT EXISTS standard_sections ( + designation TEXT PRIMARY KEY, + series TEXT NOT NULL, + standard TEXT NOT NULL, + height_mm REAL NOT NULL, + flange_width_mm REAL NOT NULL, + web_thickness_mm REAL NOT NULL, + flange_thickness_mm REAL NOT NULL, + area_cm2 REAL NOT NULL, + mass_per_metre_kg_m REAL NOT NULL, + second_moment_cm4 REAL NOT NULL, + section_modulus_cm3 REAL NOT NULL, + reference_id TEXT +); `; export function createDatabase(path: string): DatabaseSync { @@ -93,4 +108,28 @@ export function seedIfEmpty(db: DatabaseSync): void { insert.run(f.nominalDiameterMm, f.pitchMm, f.pitchDiameterMm, f.minorDiameterMm, "iso-724"); } } + + if (tableIsEmpty(db, "standard_sections")) { + const insert = db.prepare( + `INSERT INTO standard_sections + (designation, series, standard, height_mm, flange_width_mm, web_thickness_mm, flange_thickness_mm, area_cm2, mass_per_metre_kg_m, second_moment_cm4, section_modulus_cm3, reference_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const s of loadSections()) { + insert.run( + s.designation, + s.series, + s.standard, + s.heightMm, + s.flangeWidthMm, + s.webThicknessMm, + s.flangeThicknessMm, + s.areaCm2, + s.massPerMetreKgM, + s.secondMomentCm4, + s.sectionModulusCm3, + "en-10365", + ); + } + } } diff --git a/src/handlers.ts b/src/handlers.ts index c646e73..4941a2e 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -29,6 +29,15 @@ const MATERIAL_METHOD: MethodRecord = { referenceIds: [], }; +const SECTION_CATALOG_METHOD: MethodRecord = { + id: "section-catalog", + name: "Standard section catalog lookup", + formula: "Database query of published rolled-section dimensions and section properties", + notes: + "Dimensions, masses, and section properties follow the nominal values in the cited standard. Fillets and root radii are included.", + referenceIds: ["en-10365"], +}; + const UNIT_METHOD: MethodRecord = { id: "unit-convert", name: "Dimension-safe unit conversion", @@ -108,6 +117,26 @@ type MaterialValues = { yieldStrengthPa?: number; }; +type SectionInput = SectionDef | { shape: "standard"; designation: string }; + +function resolveStandardSection( + ctx: AppContext, + section: SectionInput | undefined, +): { secondMomentOfArea: number; sectionModulus: number } | { error: string } { + if (!section || section.shape !== "standard") { + return { secondMomentOfArea: 0, sectionModulus: 0 }; + } + const row = ctx.findSection(section.designation); + if (!row) { + const series = ctx.listSectionSeries().join(", "); + return { error: `Unknown standard section: ${section.designation}. Available series: ${series}` }; + } + return { + secondMomentOfArea: row.secondMomentCm4 * 1e-8, + sectionModulus: row.sectionModulusCm3 * 1e-6, + }; +} + function materialValues(ctx: AppContext, name: string): MaterialValues | undefined { const material = ctx.findMaterial(name); if (!material) { @@ -135,6 +164,13 @@ function beamHandler(ctx: AppContext): Handler { } const yieldStrength = (input.yieldStrength as number | undefined) ?? values?.yieldStrengthPa; + const section = input.section as SectionInput | undefined; + const resolved = resolveStandardSection(ctx, section); + if ("error" in resolved) { + return failure("beam_bending", resolved.error, input); + } + const usesStandard = section?.shape === "standard"; + try { const computation = analyzeBeam({ support: input.support as "simply_supported" | "cantilever", @@ -143,10 +179,13 @@ function beamHandler(ctx: AppContext): Handler { length: input.length as number, elasticModulus, yieldStrength, - section: input.section as SectionDef | undefined, - secondMomentOfArea: input.secondMomentOfArea as number | undefined, - sectionModulus: input.sectionModulus as number | undefined, + section: section && section.shape !== "standard" ? (section as SectionDef) : undefined, + secondMomentOfArea: usesStandard ? resolved.secondMomentOfArea : (input.secondMomentOfArea as number | undefined), + sectionModulus: usesStandard ? resolved.sectionModulus : (input.sectionModulus as number | undefined), }); + if (usesStandard) { + computation.referenceIds = [...computation.referenceIds, "en-10365"]; + } return buildResult(ctx, "beam_bending", computation, input.outputUnits as Record | undefined); } catch (error) { return failure("beam_bending", error instanceof Error ? error.message : String(error), input); @@ -156,8 +195,77 @@ function beamHandler(ctx: AppContext): Handler { function sectionPropsHandler(ctx: AppContext): Handler { return (input) => { + const section = input.section as SectionInput; + if (section.shape === "standard") { + const row = ctx.findSection(section.designation); + if (!row) { + const series = ctx.listSectionSeries().join(", "); + return failure("section_properties", `Unknown standard section: ${section.designation}. Available series: ${series}`, input); + } + const secondMomentOfArea = row.secondMomentCm4 * 1e-8; + const area = row.areaCm2 * 1e-4; + const computation: Computation = { + method: SECTION_CATALOG_METHOD, + inputs: { designation: row.designation, series: row.series, standard: row.standard }, + quantities: [ + { + key: "height", + label: "Section height", + value: row.heightMm * 1e-3, + unit: "m", + description: "Overall height of the rolled section.", + }, + { + key: "flangeWidth", + label: "Flange width", + value: row.flangeWidthMm * 1e-3, + unit: "m", + description: "Width across the flanges.", + }, + { + key: "area", + label: "Cross-section area", + value: area, + unit: "m2", + description: "Nominal cross-section area from the standard tables.", + }, + { + key: "massPerMetre", + label: "Mass per metre", + value: row.massPerMetreKgM, + unit: "kg/m", + description: "Nominal mass per unit length from the standard tables.", + }, + { + key: "secondMomentOfArea", + label: "Second moment of area (x-x)", + value: secondMomentOfArea, + unit: "m4", + description: "Published second moment of area about the strong axis.", + }, + { + key: "sectionModulus", + label: "Section modulus (x-x)", + value: row.sectionModulusCm3 * 1e-6, + unit: "m3", + description: "Published elastic section modulus about the strong axis.", + }, + { + key: "radiusOfGyration", + label: "Radius of gyration", + value: Math.sqrt(secondMomentOfArea / area), + unit: "m", + description: "Radius of gyration about the strong axis, derived from the published values.", + }, + ], + referenceIds: ["en-10365"], + warnings: [], + }; + return buildResult(ctx, "section_properties", computation, input.outputUnits as Record | undefined); + } + try { - const props = computeSection(input.section as SectionDef); + const props = computeSection(section as SectionDef); const computation: Computation = { method: { id: "section-properties", @@ -437,6 +545,39 @@ function materialHandler(ctx: AppContext): Handler { }; } +function sectionCatalogHandler(ctx: AppContext): Handler { + return (input) => { + const query = input.query as string; + const limit = (input.limit as number | undefined) ?? 10; + const rows = ctx.searchSections(query, limit); + if (rows.length === 0) { + return failure("section_catalog", `No standard section matches the query: ${query}`, input); + } + return { + ok: true, + tool: "section_catalog", + method: SECTION_CATALOG_METHOD, + inputs: { query, limit }, + quantities: [], + references: resolveReferences(ctx, SECTION_CATALOG_METHOD.referenceIds), + warnings: [], + rows: rows.map((row) => ({ + designation: row.designation, + series: row.series, + standard: row.standard, + heightMm: row.heightMm, + flangeWidthMm: row.flangeWidthMm, + webThicknessMm: row.webThicknessMm, + flangeThicknessMm: row.flangeThicknessMm, + areaCm2: row.areaCm2, + massPerMetreKgM: row.massPerMetreKgM, + secondMomentCm4: row.secondMomentCm4, + sectionModulusCm3: row.sectionModulusCm3, + })), + }; + }; +} + export function createHandlers(ctx: AppContext): Record { return { beam_bending: beamHandler(ctx), @@ -448,5 +589,6 @@ export function createHandlers(ctx: AppContext): Record { von_mises: stressHandler(ctx), unit_convert: unitConvertHandler(ctx), material_lookup: materialHandler(ctx), + section_catalog: sectionCatalogHandler(ctx), }; } diff --git a/src/index.ts b/src/index.ts index 8e439d3..d70ebad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,11 @@ Options: -h, --help Show this help and exit. `; -function info(message: string): void { +function writeOut(message: string): void { + process.stdout.write(`${message}\n`); +} + +function writeErr(message: string): void { process.stderr.write(`${message}\n`); } @@ -35,17 +39,17 @@ function main(): void { }); if (values.help) { - info(HELP); + writeOut(HELP); return; } if (values.version) { - info(`${SERVER_NAME} v${VERSION}`); + writeOut(`${SERVER_NAME} v${VERSION}`); return; } if (values.list) { - info("Available tools:"); + writeOut("Available tools:"); for (const tool of listTools()) { - info(` - ${tool}`); + writeOut(` - ${tool}`); } return; } @@ -56,7 +60,7 @@ function main(): void { const transport = new StdioServerTransport(); server.connect(transport).catch((error: unknown) => { - info(`Failed to start server: ${error instanceof Error ? error.message : String(error)}`); + writeErr(`Failed to start server: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); }); } diff --git a/src/schemas.ts b/src/schemas.ts index 7204c18..d163c85 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -28,6 +28,10 @@ export const sectionSchema = z.discriminatedUnion("shape", [ height: z.number().positive().describe("Outer height in metres."), thickness: z.number().positive().describe("Wall thickness in metres."), }), + z.object({ + shape: z.literal("standard"), + designation: z.string().min(1).describe("Standard section designation from the catalog, for example IPE 300 or HEB 200."), + }), ]); const outputUnits = z.record(z.string()).optional().describe( @@ -131,6 +135,11 @@ export const materialSchema = z.object({ limit: z.number().int().min(1).max(50).optional().describe("Maximum number of rows to return. Defaults to 10."), }); +export const sectionCatalogSchema = z.object({ + query: z.string().min(1).describe("Designation, series, or standard to search. Matches are case-insensitive."), + limit: z.number().int().min(1).max(50).optional().describe("Maximum number of rows to return. Defaults to 10."), +}); + export type BeamInput = z.infer; export type BoltInput = z.infer; export type ShaftInput = z.infer; @@ -140,3 +149,4 @@ export type SectionPropsInput = z.infer; export type StressInput = z.infer; export type UnitConvertInput = z.infer; export type MaterialInput = z.infer; +export type SectionCatalogInput = z.infer; diff --git a/src/server.ts b/src/server.ts index 2029a75..9c2631f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import { bearingSchema, boltSchema, materialSchema, + sectionCatalogSchema, sectionPropsSchema, shaftSchema, springSchema, @@ -60,6 +61,11 @@ const TOOL_SCHEMAS: Record = { description: "Look up mechanical properties for common engineering materials from the curated database.", schema: materialSchema, }, + section_catalog: { + description: + "Look up standard rolled steel sections from the catalog. Returns published dimensions, masses, and section properties.", + schema: sectionCatalogSchema, + }, }; function asStructuredContent(value: ToolResult): Record { diff --git a/src/units/dimensions.ts b/src/units/dimensions.ts index 67dc684..64a1003 100644 --- a/src/units/dimensions.ts +++ b/src/units/dimensions.ts @@ -16,7 +16,9 @@ export const DIM_VELOCITY: Dimension = [1, 0, -1, 0, 0]; export const DIM_ACCELERATION: Dimension = [1, 0, -2, 0, 0]; export const DIM_AREA: Dimension = [2, 0, 0, 0, 0]; export const DIM_VOLUME: Dimension = [3, 0, 0, 0, 0]; +export const DIM_SECOND_MOMENT: Dimension = [4, 0, 0, 0, 0]; export const DIM_DENSITY: Dimension = [-3, 1, 0, 0, 0]; +export const DIM_LINEAR_DENSITY: Dimension = [-1, 1, 0, 0, 0]; export const DIM_STIFFNESS: Dimension = [0, 1, -2, 0, 0]; export const DIM_FREQUENCY: Dimension = [0, 0, -1, 0, 0]; diff --git a/src/units/registry.ts b/src/units/registry.ts index 64eeff9..8d7a73d 100644 --- a/src/units/registry.ts +++ b/src/units/registry.ts @@ -7,9 +7,11 @@ import { DIM_FORCE, DIM_FREQUENCY, DIM_LENGTH, + DIM_LINEAR_DENSITY, DIM_MASS, DIM_POWER, DIM_PRESSURE, + DIM_SECOND_MOMENT, DIM_STIFFNESS, DIM_TEMPERATURE, DIM_TIME, @@ -128,10 +130,19 @@ export const UNITS: UnitDef[] = [ unit({ canonical: "ft3", name: "cubic foot", category: "volume", dim: DIM_VOLUME, factor: 0.028316846592 }), unit({ canonical: "gal", name: "US gallon", category: "volume", dim: DIM_VOLUME, factor: 0.003785411784 }), + unit({ canonical: "m4", name: "metre to the fourth power", category: "second moment of area", dim: DIM_SECOND_MOMENT, factor: 1 }), + unit({ canonical: "cm4", name: "centimetre to the fourth power", category: "second moment of area", dim: DIM_SECOND_MOMENT, factor: 1e-8 }), + unit({ canonical: "mm4", name: "millimetre to the fourth power", category: "second moment of area", dim: DIM_SECOND_MOMENT, factor: 1e-12 }), + unit({ canonical: "in4", name: "inch to the fourth power", category: "second moment of area", dim: DIM_SECOND_MOMENT, factor: 0.0254 ** 4 }), + unit({ canonical: "kg/m3", name: "kilogram per cubic metre", category: "density", dim: DIM_DENSITY, factor: 1 }), unit({ canonical: "g/cm3", name: "gram per cubic centimetre", category: "density", dim: DIM_DENSITY, factor: 1e3 }), unit({ canonical: "lb/ft3", name: "pound per cubic foot", category: "density", dim: DIM_DENSITY, factor: 16.01846337 }), + unit({ canonical: "kg/m", name: "kilogram per metre", category: "linear mass", dim: DIM_LINEAR_DENSITY, factor: 1 }), + unit({ canonical: "g/m", name: "gram per metre", category: "linear mass", dim: DIM_LINEAR_DENSITY, factor: 1e-3 }), + unit({ canonical: "lb/ft", name: "pound per foot", category: "linear mass", dim: DIM_LINEAR_DENSITY, factor: 1.4881639435696 }), + unit({ canonical: "N/m", name: "newton per metre", category: "stiffness", dim: DIM_STIFFNESS, factor: 1 }), unit({ canonical: "N/mm", name: "newton per millimetre", category: "stiffness", dim: DIM_STIFFNESS, factor: 1e3 }), unit({ canonical: "kN/m", name: "kilonewton per metre", category: "stiffness", dim: DIM_STIFFNESS, factor: 1e3 }), diff --git a/src/version.ts b/src/version.ts index 9e86f0c..03714b1 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const VERSION = "0.2.0"; +export const VERSION = "0.3.0"; export const SERVER_NAME = "engineer-mcp"; diff --git a/tests/standard-sections.test.ts b/tests/standard-sections.test.ts new file mode 100644 index 0000000..3f11f29 --- /dev/null +++ b/tests/standard-sections.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import { createContext, type AppContext, type StandardSectionRow } from "../src/context.js"; +import { createHandlers, type Handler } from "../src/handlers.js"; +import type { ToolResult } from "../src/types.js"; + +type Handlers = { + section_catalog: Handler; + section_properties: Handler; + beam_bending: Handler; +}; + +let ctx: AppContext; +let handlers: Handlers; + +function setup() { + ctx = createContext(":memory:"); + handlers = createHandlers(ctx) as Handlers; +} + +function expectOk(response: Awaited>): ToolResult { + expect(response.ok).toBe(true); + return response as ToolResult; +} + +describe("standard section catalog", () => { + it("loads the catalog and finds a section by exact designation", () => { + setup(); + const section = ctx.findSection("IPE 300"); + expect(section).toBeDefined(); + expect(section?.series).toBe("IPE"); + expect(section?.heightMm).toBe(300); + expect(section?.secondMomentCm4).toBe(8356); + expect(section?.sectionModulusCm3).toBe(557); + }); + + it("returns undefined for an unknown designation", () => { + setup(); + expect(ctx.findSection("XYZ 999")).toBeUndefined(); + }); + + it("searches across designation, series, and standard", () => { + setup(); + const bySeries = ctx.searchSections("hea"); + expect(bySeries.length).toBeGreaterThan(0); + expect(bySeries.every((row) => row.series === "HEA")).toBe(true); + + const byStandard = ctx.searchSections("en 10365", 5); + expect(byStandard.length).toBeGreaterThan(0); + expect(byStandard.length).toBeLessThanOrEqual(5); + }); + + it("lists the distinct series", () => { + setup(); + const series = ctx.listSectionSeries(); + expect(series).toEqual(["HEA", "HEB", "IPE", "UPN"]); + }); + + it("orders search results by series and height", () => { + setup(); + const rows = ctx.searchSections("ipe", 100) as StandardSectionRow[]; + expect(rows.length).toBeGreaterThan(1); + for (let i = 1; i < rows.length; i += 1) { + expect(rows[i]?.heightMm).toBeGreaterThan(rows[i - 1]?.heightMm ?? 0); + } + }); +}); + +describe("section_catalog tool", () => { + it("returns matched rows with the section properties", () => { + setup(); + const response = handlers.section_catalog({ query: "HEB 200" }); + const result = expectOk(response); + expect(result.tool).toBe("section_catalog"); + expect(result.method.id).toBe("section-catalog"); + expect(result.references[0]?.id).toBe("en-10365"); + expect(result.rows?.[0]).toMatchObject({ + designation: "HEB 200", + series: "HEB", + heightMm: 200, + }); + }); + + it("returns no match as a failure", () => { + setup(); + const response = handlers.section_catalog({ query: "no-such-section" }); + expect(response.ok).toBe(false); + }); + + it("honours the limit", () => { + setup(); + const response = handlers.section_catalog({ query: "ipe", limit: 3 }); + const result = expectOk(response); + expect(result.rows?.length).toBeLessThanOrEqual(3); + }); +}); + +describe("section_properties tool with a standard section", () => { + it("returns published properties for a designation", () => { + setup(); + const response = handlers.section_properties({ section: { shape: "standard", designation: "IPE 300" } }); + const result = expectOk(response); + + const area = result.quantities.find((q) => q.key === "area"); + expect(area?.value).toBeCloseTo(53.8e-4, 6); + expect(area?.unit).toBe("m2"); + + const moment = result.quantities.find((q) => q.key === "secondMomentOfArea"); + expect(moment?.value).toBeCloseTo(8356e-8, 10); + expect(moment?.unit).toBe("m4"); + + const modulus = result.quantities.find((q) => q.key === "sectionModulus"); + expect(modulus?.value).toBeCloseTo(557e-6, 9); + expect(modulus?.unit).toBe("m3"); + + const mass = result.quantities.find((q) => q.key === "massPerMetre"); + expect(mass?.value).toBe(42.2); + expect(mass?.unit).toBe("kg/m"); + + expect(result.references.some((ref) => ref.id === "en-10365")).toBe(true); + }); + + it("converts quantities on request", () => { + setup(); + const response = handlers.section_properties({ + section: { shape: "standard", designation: "HEA 200" }, + outputUnits: { secondMomentOfArea: "cm4", massPerMetre: "g/m" }, + }); + const result = expectOk(response); + + const moment = result.quantities.find((q) => q.key === "secondMomentOfArea"); + expect(moment?.unit).toBe("cm4"); + expect(moment?.value).toBeCloseTo(3692, 3); + + const mass = result.quantities.find((q) => q.key === "massPerMetre"); + expect(mass?.unit).toBe("g/m"); + expect(mass?.value).toBeCloseTo(42300, 1); + }); + + it("reports an unknown designation", () => { + setup(); + const response = handlers.section_properties({ section: { shape: "standard", designation: "XYZ 999" } }); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toContain("Unknown standard section"); + } + }); +}); + +describe("beam_bending tool with a standard section", () => { + it("uses the published section properties", () => { + setup(); + const response = handlers.beam_bending({ + support: "simply_supported", + load: "point", + loadMagnitude: 20000, + length: 3, + material: "Structural steel S355", + section: { shape: "standard", designation: "IPE 300" }, + outputUnits: { maxBendingStress: "MPa", maxDeflection: "mm" }, + }); + const result = expectOk(response); + + const expectedMoment = (20000 * 3) / 4; + const expectedStressPa = expectedMoment / (557e-6); + const expectedDeflection = (20000 * 3 ** 3) / (48 * 210e9 * 8356e-8); + + expect(result.quantities.find((q) => q.key === "maxBendingMoment")?.value).toBeCloseTo(expectedMoment, 6); + expect(result.quantities.find((q) => q.key === "maxBendingStress")?.value).toBeCloseTo(expectedStressPa / 1e6, 6); + expect(result.quantities.find((q) => q.key === "maxDeflection")?.value).toBeCloseTo(expectedDeflection * 1000, 9); + expect(result.references.some((ref) => ref.id === "en-10365")).toBe(true); + }); + + it("reports an unknown standard designation", () => { + setup(); + const response = handlers.beam_bending({ + support: "simply_supported", + load: "point", + loadMagnitude: 1000, + length: 2, + material: "Structural steel S355", + section: { shape: "standard", designation: "XYZ 999" }, + }); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toContain("Unknown standard section"); + } + }); +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index dd63bf0..9cdaec0 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -15,6 +15,7 @@ type Handlers = { von_mises: Handler; unit_convert: Handler; material_lookup: Handler; + section_catalog: Handler; }; let ctx: AppContext; @@ -31,7 +32,7 @@ function expectOk(response: Awaited>): ToolResult { } describe("tool registry", () => { - it("registers all nine tools", () => { + it("registers all ten tools", () => { expect(listTools().sort()).toEqual( [ "beam_bending", @@ -43,6 +44,7 @@ describe("tool registry", () => { "von_mises", "unit_convert", "material_lookup", + "section_catalog", ].sort(), ); }); diff --git a/tests/units.test.ts b/tests/units.test.ts index 8839b35..70402ee 100644 --- a/tests/units.test.ts +++ b/tests/units.test.ts @@ -135,3 +135,49 @@ describe("stiffness units", () => { } }); }); + +describe("second moment of area units", () => { + it("converts cm4 to m4", () => { + const outcome = convertUnit(8356, "cm4", "m4"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(8356e-8, 9); + expect(outcome.category).toBe("second moment of area"); + } + }); + + it("converts m4 to mm4", () => { + const outcome = convertUnit(1, "m4", "mm4"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(1e12, 9); + } + }); + + it("rejects a second moment to area conversion", () => { + const outcome = convertUnit(1, "m4", "m2"); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.error).toContain("Dimension mismatch"); + } + }); +}); + +describe("linear mass units", () => { + it("converts kilograms per metre to grams per metre", () => { + const outcome = convertUnit(42.2, "kg/m", "g/m"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(42200, 3); + expect(outcome.category).toBe("linear mass"); + } + }); + + it("converts pounds per foot to kilograms per metre", () => { + const outcome = convertUnit(1, "lb/ft", "kg/m"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(1.4881639435696, 6); + } + }); +}); From 346ac5477066a23009e336a8aee7b2e64e88f16c Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:20:06 -0700 Subject: [PATCH 2/8] feat: extend engineer mcp --- .github/workflows/ci.yml | 1 + README.md | 33 +++++- data/references.json | 7 ++ docs/integration.md | 2 +- docs/mcp-tools.md | 50 ++++++++ examples/demo.ts | 250 ++++++++++++++++++++++----------------- package.json | 1 + src/engine/fit.ts | 202 +++++++++++++++++++++++++++++++ src/engine/index.ts | 1 + src/handlers.ts | 27 +++++ src/index.ts | 17 ++- src/schemas.ts | 18 +++ src/server.ts | 6 + tests/fit.test.ts | 103 ++++++++++++++++ tests/tools.test.ts | 47 +++++++- 15 files changed, 642 insertions(+), 123 deletions(-) create mode 100644 src/engine/fit.ts create mode 100644 tests/fit.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50a783e..aaebeda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,7 @@ jobs: node dist/index.js --version | grep -q "^engineer-mcp" node dist/index.js --list | grep -q "beam_bending" node dist/index.js --list | grep -q "section_catalog" + node dist/index.js --list | grep -q "interference_fit" - name: Verify the demo runs run: npm run demo diff --git a/README.md b/README.md index b352615..96dfff5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Node](https://img.shields.io/badge/Node-%3E%3D22.13-brightgreen.svg)](package.json) Engineer MCP is a Model Context Protocol server for mechanical-engineering calculations. -It gives coding agents verified answers for beams, bolts, springs, shafts, bearings, stress, sections, and units. +It gives coding agents verified answers for beams, bolts, springs, shafts, bearings, stress, sections, fits, and units. Every result shows the formula, the method, and the source. ## What it provides @@ -23,6 +23,7 @@ The release covers these domains: - Bearing rating life to ISO 281. - von Mises equivalent stress. - Cross-section properties. +- Press and shrink fit analysis by Lamé theory. - Standard steel section catalog to EN 10365. - Dimension-safe unit conversion. - Material property lookup. @@ -38,7 +39,7 @@ The unit layer knows the dimension of every unit. It rejects a conversion between incompatible quantities. For example, it rejects a torque-to-energy conversion. -Safety factors appear only when you provide a yield strength. +Safety factors appear only when you provide the data they need. The tool never hides an assumption. Warnings surface when a method uses an approximation. @@ -49,6 +50,7 @@ Warnings surface when a method uses an approximation. | `beam_bending` | Bending stress, deflection, and safety factor. | | `section_properties` | Area, inertia, and section modulus of a shape. | | `bolt_strength` | Stress area, preload, and capacity of a bolt. | +| `interference_fit` | Interface pressure, hoop stresses, and friction capacity of a press or shrink fit. | | `spring_design` | Spring rate, shear stress, and safety factor of a compression spring. | | `shaft_analysis` | Torsion stress, twist, and critical speed. | | `bearing_life` | ISO 281 rating life in revolutions and hours. | @@ -148,6 +150,26 @@ References: - Machinery's Handbook (Thirty-first edition) ``` +A call to `interference_fit` for a steel hub on a solid steel shaft with 50 µm of diametral interference: + +```text +Interface pressure 77.62 MPa +Hub tangential stress 129.4 MPa +Shaft tangential stress 77.62 MPa +Axial force capacity 73.16 kN +Torque capacity 1.829 kN·m +Shaft safety factor 3.865 +Hub safety factor 1.656 +Torque safety factor 1.829 + +Method: Interference fit by Lamé thick-cylinder theory +Formula: p = delta / (d K); K = (1/Eh)((Ro^2+r^2)/(Ro^2-r^2)+nu_h) + (1/Ei)((r^2+ri^2)/(r^2-ri^2)-nu_i); F = 2 pi r L p mu; T = F r +References: + - Shigley's Mechanical Engineering Design (Tenth edition, 2015) + - Machinery's Handbook (Thirty-first edition) + - Theory of Elasticity (Lamé solution for thick-walled cylinders) +``` + A call to `unit_convert` with a torque-to-energy request fails safely: ```text @@ -198,7 +220,7 @@ References: The test suite is deterministic and offline. It covers the engines, the unit layer, the database, and the tools. -- 116 tests across 11 files. +- 128 tests across 12 files. - All tests pass on Node 22 and Node 24. - The CI workflow runs typecheck, tests, build, demo, and a package check. - The CI workflow verifies the CLI contract over standard output. @@ -214,6 +236,8 @@ Run `npm test` to reproduce the results. - The critical speed is a first-mode approximation. - The spring design covers static round-wire springs only. It does not estimate fatigue life for cyclic loads. +- The press-fit theory assumes elastic material behavior and uniform friction. + It does not model residual stress after yield. - The section catalog covers common IPE, HEA, HEB, and UPN sizes. It does not include every size in the standard. - The built-in SQLite module of Node.js is still experimental. @@ -232,11 +256,12 @@ Each release stays useful on its own. - Standard steel section catalog. The `section_catalog` tool searches the published IPE, HEA, HEB, and UPN series. The `beam_bending` and `section_properties` tools accept a catalog designation. +- Press and shrink fit analysis. + The `interference_fit` tool reports the interface pressure, the hoop stresses, and the friction capacity. ### Remaining - Add fatigue analysis for cyclic loads. -- Add press-fit and interference-fit calculators. - Add more unit categories, including viscosity and thermal conductivity. - Add HTTP transport. diff --git a/data/references.json b/data/references.json index b241946..62be926 100644 --- a/data/references.json +++ b/data/references.json @@ -61,5 +61,12 @@ "edition": "EN 10365:2017", "section": "Dimensions, masses, and section properties of rolled sections", "note": "Nominal dimensions and section properties for IPE, HEA, HEB, and UPN series." + }, + "lame-cylinders": { + "title": "Theory of Elasticity", + "source": "Timoshenko and Goodier, Lamé solution for thick-walled cylinders", + "edition": "Any standard university text", + "section": "Thick-walled cylinders under internal and external pressure", + "note": "Provides the interface pressure and the stress state of a press or shrink fit." } } diff --git a/docs/integration.md b/docs/integration.md index 58e8272..3e80fee 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -42,11 +42,11 @@ Engineer MCP grows in independent releases. Each release stays useful on its own - Helical compression spring design. The `spring_design` tool computes the spring rate, the shear stress, and the safety factor. - Standard section catalog. The `section_catalog` tool searches published IPE, HEA, HEB, and UPN sections. The `beam_bending` and `section_properties` tools accept a standard designation. +- Press and shrink fit analysis. The `interference_fit` tool computes the interface pressure, the hoop stresses, and the friction capacity. ### Remaining - Add fatigue analysis for cyclic loads. -- Add press-fit and interference-fit calculators. - Add more unit categories, including viscosity and thermal conductivity. - Add HTTP transport in addition to stdio. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index c6914f9..4834db8 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -189,6 +189,56 @@ Inputs: The converter rejects mismatched dimensions and mismatched quantity categories. For example, it rejects a torque-to-energy conversion. +## interference_fit + +Compute the interface pressure, hoop stresses, and friction capacity of a press or shrink fit. + +The tool uses the Lamé solution for thick-walled cylinders. +The diametral interference drives a uniform contact pressure at the interface. +The fit can be a press fit or a shrink fit. The math is the same. + +Inputs: + +- `interfaceRadius`: interface radius in metres. +- `hubOuterRadius`: outer radius of the hub in metres. +- `shaftInnerRadius`: inner radius of the shaft in metres. The default is zero for a solid shaft. +- `interference`: diametral interference in metres. +- `length`: axial length of the joint in metres. +- `frictionCoefficient`: friction of the interface. The default is `0.15`. +- `shaftElasticModulus` and `shaftPoissonRatio`: shaft material. Poisson ratio defaults to `0.3`. +- `shaftYieldStrength`: shaft yield strength in pascals. It enables the shaft safety factor. +- `hubElasticModulus` and `hubPoissonRatio`: hub material. Poisson ratio defaults to `0.3`. +- `hubYieldStrength`: hub yield strength in pascals. It enables the hub safety factor. +- `requiredTorque`: required torque in newton metres. It enables the torque safety factor. + +Outputs: + +- `interfacePressure`: contact pressure at the interface. +- `hubTangentialStress`: hoop stress at the hub bore. +- `shaftTangentialStress`: hoop stress in the shaft. A hollow shaft peaks at its bore. +- `axialForceCapacity` and `torqueCapacity`: friction limits of the joint. +- `shaftSafetyFactor`, `hubSafetyFactor`, and `torqueSafetyFactor`: available margins. +- The envelope `safetyFactor` is the governing margin. It is the lowest of the three. + +The tool warns when a member exceeds its yield strength. +It warns when the torque capacity is below the required torque. + +Example: + +```json +{ + "interfaceRadius": 0.025, + "hubOuterRadius": 0.05, + "interference": 0.00005, + "length": 0.05, + "shaftElasticModulus": 207000000000, + "shaftYieldStrength": 300000000, + "hubElasticModulus": 207000000000, + "hubYieldStrength": 300000000, + "requiredTorque": 1000 +} +``` + ## material_lookup Look up mechanical properties of common engineering materials. diff --git a/examples/demo.ts b/examples/demo.ts index cc0b3ce..835b109 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -1,12 +1,8 @@ import "../src/warnings.js"; -import { createContext, type AppContext } from "../src/context.js"; import { createHandlers, type Handler } from "../src/handlers.js"; import { formatNumber } from "../src/results.js"; import type { ToolResponse } from "../src/types.js"; -const ctx: AppContext = createContext(":memory:"); -const handlers = createHandlers(ctx); - type NamedHandler = [string, Handler]; function banner(title: string): void { @@ -25,10 +21,13 @@ function show(name: string, handler: Handler, input: Record): v return; } + const printed = new Set(); + for (const quantity of response.quantities) { const unit = quantity.unit ? ` ${quantity.unit}` : ""; console.log(`${quantity.label.padEnd(28)} ${formatNumber(quantity.value).padStart(14)}${unit}`); console.log(` ${quantity.description}`); + printed.add(quantity.key); } if (response.rows && response.rows.length > 0) { @@ -44,9 +43,10 @@ function show(name: string, handler: Handler, input: Record): v } } - if (response.safetyFactor) { + if (response.safetyFactor && !printed.has(response.safetyFactor.key)) { console.log(`${response.safetyFactor.label.padEnd(28)} ${formatNumber(response.safetyFactor.value).padStart(14)}`); console.log(` ${response.safetyFactor.description}`); + printed.add(response.safetyFactor.key); } if (response.meta) { @@ -76,6 +76,7 @@ type ToolHandlers = { beam_bending: Handler; section_properties: Handler; bolt_strength: Handler; + interference_fit: Handler; spring_design: Handler; shaft_analysis: Handler; bearing_life: Handler; @@ -85,111 +86,138 @@ type ToolHandlers = { section_catalog: Handler; }; -const toolHandlers = handlers as ToolHandlers; - -const tools: NamedHandler[] = [ - ["beam_bending", toolHandlers.beam_bending], - ["section_properties", toolHandlers.section_properties], - ["bolt_strength", toolHandlers.bolt_strength], - ["spring_design", toolHandlers.spring_design], - ["shaft_analysis", toolHandlers.shaft_analysis], - ["bearing_life", toolHandlers.bearing_life], - ["von_mises", toolHandlers.von_mises], - ["unit_convert", toolHandlers.unit_convert], - ["unit_convert (torque to energy)", toolHandlers.unit_convert], - ["material_lookup", toolHandlers.material_lookup], - ["section_catalog", toolHandlers.section_catalog], - ["beam_bending (IPE 300)", toolHandlers.beam_bending], -]; - -const inputs: Array> = [ - { - support: "simply_supported", - load: "point", - loadMagnitude: 20000, - length: 3, - material: "Structural steel S355", - section: { shape: "i_beam", height: 0.3, flangeWidth: 0.15, flangeThickness: 0.012, webThickness: 0.008 }, - outputUnits: { maxBendingStress: "MPa", maxDeflection: "mm", maxBendingMoment: "kN·m" }, - }, - { - section: { shape: "i_beam", height: 0.3, flangeWidth: 0.15, flangeThickness: 0.012, webThickness: 0.008 }, - }, - { - nominalDiameterMm: 12, - propertyClass: "8.8", - axialLoad: 30000, - outputUnits: { recommendedPreload: "kN" }, - }, - { - wireDiameter: 0.008, - meanDiameter: 0.04, - activeCoils: 4, - endType: "squared_ground", - freeLength: 0.09, - load: 2000, - shearModulus: 79.3e9, - shearYieldStrength: 700e6, - outputUnits: { maxShearStress: "MPa", springRate: "N/mm", deflection: "mm", solidHeight: "mm", workingLength: "mm" }, - }, - { - outerDiameter: 0.05, - length: 1.2, - torque: 1500, - material: "Structural steel S355", - outputUnits: { maxShearStress: "MPa" }, - }, - { - bearingType: "ball", - dynamicLoadRating: 42300, - equivalentLoad: 8500, - speedRpm: 1500, - requiredLifeHours: 20000, - }, - { - mode: "cartesian", - sigmaX: 120e6, - sigmaY: 40e6, - tauXY: 25e6, - yieldStrength: 355e6, - outputUnits: { vonMisesStress: "MPa", maxShearStress: "MPa" }, - }, - { - value: 1000, - from: "psi", - to: "MPa", - }, - { - value: 10, - from: "N·m", - to: "J", - }, - { - query: "steel", - }, - { - query: "IPE", - limit: 4, - }, - { - support: "simply_supported", - load: "point", - loadMagnitude: 20000, - length: 3, - material: "Structural steel S355", - section: { shape: "standard", designation: "IPE 300" }, - outputUnits: { maxBendingStress: "MPa", maxDeflection: "mm", maxBendingMoment: "kN·m" }, - }, -]; - -console.log("Engineer MCP - demo run"); -for (let i = 0; i < tools.length; i += 1) { - const entry = tools[i]; - const input = inputs[i]; - if (!entry || !input) { - continue; +async function main(): Promise { + const { createContext } = await import("../src/context.js"); + const ctx = createContext(":memory:"); + const handlers = createHandlers(ctx); + const toolHandlers = handlers as ToolHandlers; + + const tools: NamedHandler[] = [ + ["beam_bending", toolHandlers.beam_bending], + ["section_properties", toolHandlers.section_properties], + ["bolt_strength", toolHandlers.bolt_strength], + ["spring_design", toolHandlers.spring_design], + ["shaft_analysis", toolHandlers.shaft_analysis], + ["bearing_life", toolHandlers.bearing_life], + ["von_mises", toolHandlers.von_mises], + ["unit_convert", toolHandlers.unit_convert], + ["unit_convert (torque to energy)", toolHandlers.unit_convert], + ["material_lookup", toolHandlers.material_lookup], + ["section_catalog", toolHandlers.section_catalog], + ["beam_bending (IPE 300)", toolHandlers.beam_bending], + ["interference_fit", toolHandlers.interference_fit], + ]; + + const inputs: Array> = [ + { + support: "simply_supported", + load: "point", + loadMagnitude: 20000, + length: 3, + material: "Structural steel S355", + section: { shape: "i_beam", height: 0.3, flangeWidth: 0.15, flangeThickness: 0.012, webThickness: 0.008 }, + outputUnits: { maxBendingStress: "MPa", maxDeflection: "mm", maxBendingMoment: "kN·m" }, + }, + { + section: { shape: "i_beam", height: 0.3, flangeWidth: 0.15, flangeThickness: 0.012, webThickness: 0.008 }, + }, + { + nominalDiameterMm: 12, + propertyClass: "8.8", + axialLoad: 30000, + outputUnits: { recommendedPreload: "kN" }, + }, + { + wireDiameter: 0.008, + meanDiameter: 0.04, + activeCoils: 4, + endType: "squared_ground", + freeLength: 0.09, + load: 2000, + shearModulus: 79.3e9, + shearYieldStrength: 700e6, + outputUnits: { maxShearStress: "MPa", springRate: "N/mm", deflection: "mm", solidHeight: "mm", workingLength: "mm" }, + }, + { + outerDiameter: 0.05, + length: 1.2, + torque: 1500, + material: "Structural steel S355", + outputUnits: { maxShearStress: "MPa" }, + }, + { + bearingType: "ball", + dynamicLoadRating: 42300, + equivalentLoad: 8500, + speedRpm: 1500, + requiredLifeHours: 20000, + }, + { + mode: "cartesian", + sigmaX: 120e6, + sigmaY: 40e6, + tauXY: 25e6, + yieldStrength: 355e6, + outputUnits: { vonMisesStress: "MPa", maxShearStress: "MPa" }, + }, + { + value: 1000, + from: "psi", + to: "MPa", + }, + { + value: 10, + from: "N·m", + to: "J", + }, + { + query: "steel", + }, + { + query: "IPE", + limit: 4, + }, + { + support: "simply_supported", + load: "point", + loadMagnitude: 20000, + length: 3, + material: "Structural steel S355", + section: { shape: "standard", designation: "IPE 300" }, + outputUnits: { maxBendingStress: "MPa", maxDeflection: "mm", maxBendingMoment: "kN·m" }, + }, + { + interfaceRadius: 0.025, + hubOuterRadius: 0.05, + interference: 5e-5, + length: 0.05, + frictionCoefficient: 0.12, + shaftElasticModulus: 207e9, + shaftYieldStrength: 300e6, + hubElasticModulus: 207e9, + hubYieldStrength: 300e6, + requiredTorque: 1000, + outputUnits: { + interfacePressure: "MPa", + hubTangentialStress: "MPa", + shaftTangentialStress: "MPa", + axialForceCapacity: "kN", + torqueCapacity: "kN·m", + }, + }, + ]; + + console.log("Engineer MCP - demo run"); + for (let i = 0; i < tools.length; i += 1) { + const entry = tools[i]; + const input = inputs[i]; + if (!entry || !input) { + continue; + } + const [name, handler] = entry; + show(name, handler, input); } - const [name, handler] = entry; - show(name, handler, input); + console.log(""); } -console.log(""); + +main(); diff --git a/package.json b/package.json index b31a786..8cf90f2 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "sqlite", "provenance", "steel-sections", + "press-fit", "engineerkit" ], "scripts": { diff --git a/src/engine/fit.ts b/src/engine/fit.ts new file mode 100644 index 0000000..9843a9a --- /dev/null +++ b/src/engine/fit.ts @@ -0,0 +1,202 @@ +import type { Computation, MethodRecord, Quantity } from "../types.js"; + +export type FitInput = { + interfaceRadius: number; + hubOuterRadius: number; + shaftInnerRadius?: number; + interference: number; + length: number; + frictionCoefficient?: number; + shaftElasticModulus: number; + shaftPoissonRatio?: number; + shaftYieldStrength?: number; + hubElasticModulus: number; + hubPoissonRatio?: number; + hubYieldStrength?: number; + requiredTorque?: number; +}; + +export const PRESS_FIT_METHOD: MethodRecord = { + id: "press-fit", + name: "Interference fit by Lamé thick-cylinder theory", + formula: + "p = delta / (d K); K = (1/Eh)((Ro^2+r^2)/(Ro^2-r^2)+nu_h) + (1/Ei)((r^2+ri^2)/(r^2-ri^2)-nu_i); F = 2 pi r L p mu; T = F r", + notes: + "The diametral interference drives a uniform contact pressure. Hoop stress peaks at the hub bore and, for a hollow shaft, at the shaft bore. The axial and torque capacities use Coulomb friction over the full interface area. The equivalent stress uses the plane-stress von Mises rule from the hoop and radial components.", + referenceIds: ["shigley-2015", "machinery-handbook", "lame-cylinders"], +}; + +function equivalentStress(hoop: number, radial: number): number { + return Math.sqrt(hoop ** 2 + radial ** 2 - hoop * radial); +} + +export function analyzePressFit(input: FitInput): Computation { + const interfaceRadius = input.interfaceRadius; + const hubOuterRadius = input.hubOuterRadius; + const shaftInnerRadius = input.shaftInnerRadius ?? 0; + const friction = input.frictionCoefficient ?? 0.15; + + if (!(interfaceRadius > 0)) { + throw new Error("interfaceRadius must be positive."); + } + if (!(hubOuterRadius > interfaceRadius)) { + throw new Error("hubOuterRadius must exceed interfaceRadius."); + } + if (!(shaftInnerRadius >= 0 && shaftInnerRadius < interfaceRadius)) { + throw new Error("shaftInnerRadius must be zero or less than interfaceRadius."); + } + if (!(input.interference > 0)) { + throw new Error("interference must be positive. A clearance or line fit produces no pressure."); + } + if (!(input.length > 0)) { + throw new Error("length must be positive."); + } + if (!(input.shaftElasticModulus > 0)) { + throw new Error("shaftElasticModulus must be positive."); + } + if (!(input.hubElasticModulus > 0)) { + throw new Error("hubElasticModulus must be positive."); + } + if (!(friction > 0 && friction < 1)) { + throw new Error("frictionCoefficient must be between zero and one."); + } + + const nuShaft = input.shaftPoissonRatio ?? 0.3; + const nuHub = input.hubPoissonRatio ?? 0.3; + + const r = interfaceRadius; + const ro = hubOuterRadius; + const ri = shaftInnerRadius; + const d = 2 * r; + + const shaftTerm = (r ** 2 + ri ** 2) / (r ** 2 - ri ** 2) - nuShaft; + const hubTerm = (ro ** 2 + r ** 2) / (ro ** 2 - r ** 2) + nuHub; + const compliance = shaftTerm / input.shaftElasticModulus + hubTerm / input.hubElasticModulus; + const pressure = input.interference / (d * compliance); + + const hubHoop = (pressure * (ro ** 2 + r ** 2)) / (ro ** 2 - r ** 2); + const hubRadial = -pressure; + const shaftHoop = ri === 0 ? -pressure : -(2 * pressure * r ** 2) / (r ** 2 - ri ** 2); + const shaftRadial = ri === 0 ? -pressure : 0; + + const hubTangential = Math.abs(hubHoop); + const shaftTangential = Math.abs(shaftHoop); + const hubEquivalent = equivalentStress(hubHoop, hubRadial); + const shaftEquivalent = equivalentStress(shaftHoop, shaftRadial); + + const axialForce = 2 * Math.PI * r * input.length * pressure * friction; + const torque = axialForce * r; + + const quantities: Quantity[] = [ + { + key: "interfacePressure", + label: "Interface pressure", + value: pressure, + unit: "Pa", + description: "Contact pressure at the fit interface from the diametral interference.", + }, + { + key: "hubTangentialStress", + label: "Hub tangential stress", + value: hubTangential, + unit: "Pa", + description: "Hoop stress at the hub bore. This is the highest stress in the hub.", + }, + { + key: "shaftTangentialStress", + label: "Shaft tangential stress", + value: shaftTangential, + unit: "Pa", + description: + ri === 0 + ? "Uniform compressive hoop stress in the solid shaft." + : "Hoop stress at the shaft bore, the highest stress in a hollow shaft.", + }, + { + key: "axialForceCapacity", + label: "Axial force capacity", + value: axialForce, + unit: "N", + description: "Friction force that the fit resists along the axis of the joint.", + }, + { + key: "torqueCapacity", + label: "Torque capacity", + value: torque, + unit: "N·m", + description: "Friction torque that the fit transmits without slipping.", + }, + ]; + + const safetyCandidates: Quantity[] = []; + if (input.shaftYieldStrength) { + safetyCandidates.push({ + key: "shaftSafetyFactor", + label: "Shaft safety factor", + value: input.shaftYieldStrength / shaftEquivalent, + unit: "", + description: "Shaft yield strength divided by the shaft equivalent stress.", + }); + } + if (input.hubYieldStrength) { + safetyCandidates.push({ + key: "hubSafetyFactor", + label: "Hub safety factor", + value: input.hubYieldStrength / hubEquivalent, + unit: "", + description: "Hub yield strength divided by the hub equivalent stress.", + }); + } + if (input.requiredTorque) { + safetyCandidates.push({ + key: "torqueSafetyFactor", + label: "Torque safety factor", + value: torque / input.requiredTorque, + unit: "", + description: "Torque capacity divided by the required torque.", + }); + } + + const allQuantities: Quantity[] = [...quantities, ...safetyCandidates]; + + const warnings: string[] = []; + if (input.shaftYieldStrength && shaftEquivalent > input.shaftYieldStrength) { + warnings.push("The shaft equivalent stress exceeds the shaft yield strength. The fit may yield and relax."); + } + if (input.hubYieldStrength && hubEquivalent > input.hubYieldStrength) { + warnings.push("The hub equivalent stress exceeds the hub yield strength. The fit may yield and relax."); + } + if (input.requiredTorque && torque < input.requiredTorque) { + warnings.push("The torque capacity is below the required torque. The fit may slip under load."); + } + + let safetyFactor: Quantity | undefined; + if (safetyCandidates.length > 0) { + safetyFactor = safetyCandidates.reduce((lowest, candidate) => + candidate.value < lowest.value ? candidate : lowest, + ); + } + + return { + method: PRESS_FIT_METHOD, + inputs: { + interfaceRadius: r, + hubOuterRadius: ro, + shaftInnerRadius: ri === 0 ? undefined : ri, + interference: input.interference, + length: input.length, + frictionCoefficient: friction, + shaftElasticModulus: input.shaftElasticModulus, + shaftPoissonRatio: nuShaft, + shaftYieldStrength: input.shaftYieldStrength, + hubElasticModulus: input.hubElasticModulus, + hubPoissonRatio: nuHub, + hubYieldStrength: input.hubYieldStrength, + requiredTorque: input.requiredTorque, + }, + quantities: allQuantities, + safetyFactor, + referenceIds: PRESS_FIT_METHOD.referenceIds, + warnings, + }; +} diff --git a/src/engine/index.ts b/src/engine/index.ts index 92492d7..1c358eb 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -1,6 +1,7 @@ export { analyzeBeam, BEAM_METHOD, type BeamInput, type BeamLoad, type BeamSupport } from "./beam.js"; export { analyzeBearing, BEARING_METHOD, equivalentLoad, type BearingInput, type BearingType } from "./bearing.js"; export { analyzeBolt, BOLT_METHOD, tensileStressArea, type BoltGradeData, type BoltInput } from "./bolt.js"; +export { analyzePressFit, PRESS_FIT_METHOD, type FitInput } from "./fit.js"; export { computeSection, SECTION_METHOD, type SectionDef, type SectionProperties } from "./sections.js"; export { analyzeShaft, SHAFT_METHOD, type ShaftInput } from "./shaft.js"; export { diff --git a/src/handlers.ts b/src/handlers.ts index 4941a2e..ab7800d 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -3,6 +3,7 @@ import { analyzeBeam, analyzeBearing, analyzeBolt, + analyzePressFit, analyzeShaft, analyzeSpring, computeSection, @@ -440,6 +441,31 @@ function bearingHandler(ctx: AppContext): Handler { }; } +function fitHandler(ctx: AppContext): Handler { + return (input) => { + try { + const computation = analyzePressFit({ + interfaceRadius: input.interfaceRadius as number, + hubOuterRadius: input.hubOuterRadius as number, + shaftInnerRadius: input.shaftInnerRadius as number | undefined, + interference: input.interference as number, + length: input.length as number, + frictionCoefficient: input.frictionCoefficient as number | undefined, + shaftElasticModulus: input.shaftElasticModulus as number, + shaftPoissonRatio: input.shaftPoissonRatio as number | undefined, + shaftYieldStrength: input.shaftYieldStrength as number | undefined, + hubElasticModulus: input.hubElasticModulus as number, + hubPoissonRatio: input.hubPoissonRatio as number | undefined, + hubYieldStrength: input.hubYieldStrength as number | undefined, + requiredTorque: input.requiredTorque as number | undefined, + }); + return buildResult(ctx, "interference_fit", computation, input.outputUnits as Record | undefined); + } catch (error) { + return failure("interference_fit", error instanceof Error ? error.message : String(error), input); + } + }; +} + function stressHandler(ctx: AppContext): Handler { return (input) => { const mode = input.mode as "principal" | "cartesian"; @@ -583,6 +609,7 @@ export function createHandlers(ctx: AppContext): Record { beam_bending: beamHandler(ctx), section_properties: sectionPropsHandler(ctx), bolt_strength: boltHandler(ctx), + interference_fit: fitHandler(ctx), shaft_analysis: shaftHandler(ctx), spring_design: springHandler(ctx), bearing_life: bearingHandler(ctx), diff --git a/src/index.ts b/src/index.ts index d70ebad..f6df869 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,6 @@ import "./warnings.js"; import { parseArgs } from "node:util"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { createContext } from "./context.js"; import { buildServer, listTools } from "./server.js"; import { SERVER_NAME, VERSION } from "./version.js"; @@ -55,14 +54,20 @@ function main(): void { } const dbPath = values.db ?? process.env.ENGINEER_MCP_DB ?? "engineer-mcp.sqlite"; - const ctx = createContext(dbPath); - const server = buildServer(ctx); - const transport = new StdioServerTransport(); + void startServer(dbPath); +} - server.connect(transport).catch((error: unknown) => { +async function startServer(dbPath: string): Promise { + try { + const { createContext } = await import("./context.js"); + const ctx = createContext(dbPath); + const server = buildServer(ctx); + const transport = new StdioServerTransport(); + await server.connect(transport); + } catch (error: unknown) { writeErr(`Failed to start server: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); - }); + } } main(); diff --git a/src/schemas.ts b/src/schemas.ts index d163c85..2e65055 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -124,6 +124,23 @@ export const stressSchema = z.object({ yieldStrength: z.number().positive().optional().describe("Tensile yield strength in pascals. Enables the safety factor."), }); +export const fitSchema = z.object({ + interfaceRadius: z.number().positive().describe("Interface radius of the fit in metres."), + hubOuterRadius: z.number().positive().describe("Outer radius of the hub in metres."), + shaftInnerRadius: z.number().min(0).optional().describe("Inner radius of the shaft in metres. Zero for a solid shaft."), + interference: z.number().positive().describe("Diametral interference in metres."), + length: z.number().positive().describe("Axial length of the fitted joint in metres."), + frictionCoefficient: z.number().min(0).max(1).optional().describe("Friction coefficient of the interface. Defaults to 0.15."), + shaftElasticModulus: z.number().positive().describe("Young's modulus of the shaft material in pascals."), + shaftPoissonRatio: z.number().min(0).max(0.5).optional().describe("Poisson's ratio of the shaft material. Defaults to 0.3."), + shaftYieldStrength: z.number().positive().optional().describe("Yield strength of the shaft material in pascals. Enables the safety factor."), + hubElasticModulus: z.number().positive().describe("Young's modulus of the hub material in pascals."), + hubPoissonRatio: z.number().min(0).max(0.5).optional().describe("Poisson's ratio of the hub material. Defaults to 0.3."), + hubYieldStrength: z.number().positive().optional().describe("Yield strength of the hub material in pascals. Enables the safety factor."), + requiredTorque: z.number().positive().optional().describe("Required torque in newton metres. Enables the torque safety factor."), + outputUnits, +}); + export const unitConvertSchema = z.object({ value: z.number().describe("Numeric value to convert."), from: z.string().describe("Source unit symbol. Examples: MPa, mm, lbf, degC."), @@ -149,4 +166,5 @@ export type SectionPropsInput = z.infer; export type StressInput = z.infer; export type UnitConvertInput = z.infer; export type MaterialInput = z.infer; +export type FitInput = z.infer; export type SectionCatalogInput = z.infer; diff --git a/src/server.ts b/src/server.ts index 9c2631f..b36ffe4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,7 @@ import { beamSchema, bearingSchema, boltSchema, + fitSchema, materialSchema, sectionCatalogSchema, sectionPropsSchema, @@ -36,6 +37,11 @@ const TOOL_SCHEMAS: Record = { description: "Tensile stress area, proof strength, recommended preload, and safety factor for metric bolts.", schema: boltSchema, }, + interference_fit: { + description: + "Interface pressure, hoop stresses, and friction torque capacity of a press or shrink fit by Lamé thick-cylinder theory.", + schema: fitSchema, + }, spring_design: { description: "Helical compression spring geometry, spring rate, Wahl-corrected shear stress, and safety factor for round wire.", diff --git a/tests/fit.test.ts b/tests/fit.test.ts new file mode 100644 index 0000000..f77abfa --- /dev/null +++ b/tests/fit.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { analyzePressFit } from "../src/engine/fit.js"; + +const BASE = { + interfaceRadius: 0.025, + hubOuterRadius: 0.05, + interference: 5e-5, + length: 0.05, + shaftElasticModulus: 207e9, + hubElasticModulus: 207e9, +}; + +function pressureFormula(input: typeof BASE): number { + const { interfaceRadius, hubOuterRadius, interference } = input; + const r = interfaceRadius; + const ro = hubOuterRadius; + const shaftTerm = 1 - 0.3; + const hubTerm = (ro ** 2 + r ** 2) / (ro ** 2 - r ** 2) + 0.3; + return interference / (2 * r * ((shaftTerm + hubTerm) / 207e9)); +} + +describe("press fit engine", () => { + it("computes the interface pressure from the Lamé formula", () => { + const result = analyzePressFit({ ...BASE, frictionCoefficient: 0.12 }); + const pressure = result.quantities.find((q) => q.key === "interfacePressure"); + expect(pressure?.value).toBeCloseTo(pressureFormula(BASE), 6); + expect(pressure?.value).toBeGreaterThan(7e7); + expect(pressure?.value).toBeLessThan(8e7); + }); + + it("computes the friction torque and axial force capacity", () => { + const result = analyzePressFit({ ...BASE, frictionCoefficient: 0.12 }); + const pressure = result.quantities.find((q) => q.key === "interfacePressure")?.value ?? 0; + const torque = result.quantities.find((q) => q.key === "torqueCapacity"); + const axial = result.quantities.find((q) => q.key === "axialForceCapacity"); + const expectedTorque = 2 * Math.PI * 0.025 ** 2 * 0.05 * pressure * 0.12; + expect(torque?.value).toBeCloseTo(expectedTorque, 6); + expect(axial?.value).toBeCloseTo(expectedTorque / 0.025, 6); + }); + + it("uses a uniform compressive stress for a solid shaft", () => { + const result = analyzePressFit({ ...BASE }); + const pressure = result.quantities.find((q) => q.key === "interfacePressure")?.value ?? 0; + const stress = result.quantities.find((q) => q.key === "shaftTangentialStress"); + expect(stress?.value).toBeCloseTo(pressure, 9); + }); + + it("reports the governing safety factor across shaft, hub, and torque", () => { + const result = analyzePressFit({ + ...BASE, + frictionCoefficient: 0.12, + shaftYieldStrength: 300e6, + hubYieldStrength: 300e6, + requiredTorque: 1000, + }); + expect(result.safetyFactor).toBeDefined(); + expect(result.safetyFactor?.key).toBe("hubSafetyFactor"); + expect(result.quantities.map((q) => q.key)).toContain("shaftSafetyFactor"); + expect(result.quantities.map((q) => q.key)).toContain("hubSafetyFactor"); + expect(result.quantities.map((q) => q.key)).toContain("torqueSafetyFactor"); + }); + + it("warns when a member exceeds its yield strength", () => { + const result = analyzePressFit({ + ...BASE, + shaftYieldStrength: 20e6, + hubYieldStrength: 300e6, + }); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings.join(" ")).toContain("shaft yield strength"); + }); + + it("reports a higher hoop stress at the bore of a hollow shaft", () => { + const solid = analyzePressFit({ ...BASE }); + const hollow = analyzePressFit({ ...BASE, shaftInnerRadius: 0.0125 }); + const solidPressure = solid.quantities.find((q) => q.key === "interfacePressure")?.value ?? 0; + const hollowPressure = hollow.quantities.find((q) => q.key === "interfacePressure")?.value ?? 0; + const hollowStress = hollow.quantities.find((q) => q.key === "shaftTangentialStress")?.value ?? 0; + expect(hollowStress).toBeCloseTo((2 * hollowPressure * 0.025 ** 2) / (0.025 ** 2 - 0.0125 ** 2), 6); + expect(hollowStress).toBeGreaterThan(solidPressure); + }); + + it("warns when the torque capacity falls below the required torque", () => { + const result = analyzePressFit({ + ...BASE, + frictionCoefficient: 0.05, + requiredTorque: 1e6, + }); + expect(result.warnings.some((w) => w.includes("slip"))).toBe(true); + }); + + it("rejects a clearance fit", () => { + expect(() => analyzePressFit({ ...BASE, interference: -1e-5 })).toThrow("interference"); + }); + + it("rejects an invalid hub geometry", () => { + expect(() => analyzePressFit({ ...BASE, hubOuterRadius: 0.02 })).toThrow("hubOuterRadius"); + }); + + it("rejects an invalid shaft bore", () => { + expect(() => analyzePressFit({ ...BASE, shaftInnerRadius: 0.03 })).toThrow("shaftInnerRadius"); + }); +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 9cdaec0..adfd946 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -9,6 +9,7 @@ type Handlers = { beam_bending: Handler; section_properties: Handler; bolt_strength: Handler; + interference_fit: Handler; spring_design: Handler; shaft_analysis: Handler; bearing_life: Handler; @@ -32,12 +33,13 @@ function expectOk(response: Awaited>): ToolResult { } describe("tool registry", () => { - it("registers all ten tools", () => { + it("registers all eleven tools", () => { expect(listTools().sort()).toEqual( [ "beam_bending", "section_properties", "bolt_strength", + "interference_fit", "spring_design", "shaft_analysis", "bearing_life", @@ -140,6 +142,49 @@ describe("bolt_strength tool", () => { }); }); +describe("interference_fit tool", () => { + const fitInput = { + interfaceRadius: 0.025, + hubOuterRadius: 0.05, + interference: 5e-5, + length: 0.05, + shaftElasticModulus: 207e9, + shaftYieldStrength: 300e6, + hubElasticModulus: 207e9, + hubYieldStrength: 300e6, + requiredTorque: 1000, + outputUnits: { interfacePressure: "MPa", torqueCapacity: "kN·m" }, + }; + + it("returns an envelope with provenance and converted units", () => { + setup(); + const response = handlers.interference_fit(fitInput); + const result = expectOk(response); + + expect(result.tool).toBe("interference_fit"); + expect(result.method.id).toBe("press-fit"); + expect(result.references.length).toBeGreaterThan(0); + expect(result.references.map((r) => r.id)).toContain("lame-cylinders"); + expect(result.safetyFactor).toBeDefined(); + + const pressure = result.quantities.find((q) => q.key === "interfacePressure"); + expect(pressure?.unit).toBe("MPa"); + expect(pressure?.value).toBeGreaterThan(70); + expect(pressure?.value).toBeLessThan(80); + const torque = result.quantities.find((q) => q.key === "torqueCapacity"); + expect(torque?.unit).toBe("kN·m"); + }); + + it("rejects an invalid hub geometry", () => { + setup(); + const response = handlers.interference_fit({ ...fitInput, hubOuterRadius: 0.01 }); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toContain("hubOuterRadius"); + } + }); +}); + describe("shaft_analysis tool", () => { it("computes a shaft from a material", () => { setup(); From 0b37def2a483c2a03a866aedd69478c669c346eb Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:05:09 -0700 Subject: [PATCH 3/8] feat: audit the steel section catalog and release 0.4.0 Document the covered range, the value provenance, and the data audit for the section catalog. Add deterministic tests that verify the catalog consistency against EN 10365 values. Add repository metadata and CI concurrency. Bump the version to 0.4.0. --- .editorconfig | 18 +++++++ .github/workflows/ci.yml | 4 ++ CONTRIBUTING.md | 56 +++++++++++++++++++ README.md | 5 +- SECURITY.md | 37 +++++++++++++ docs/integration.md | 2 +- docs/mcp-tools.md | 3 ++ docs/section-catalog.md | 73 +++++++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- src/version.ts | 2 +- tests/section-data-audit.test.ts | 92 ++++++++++++++++++++++++++++++++ 12 files changed, 291 insertions(+), 7 deletions(-) create mode 100644 .editorconfig create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 docs/section-catalog.md create mode 100644 tests/section-data-audit.test.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..08582b8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{ts,js,json}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.yml] +indent_style = space +indent_size = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaebeda..ed68b94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [main] pull_request: +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + jobs: verify: name: verify on Node ${{ matrix.node-version }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4727a84 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,56 @@ +# Contributing + +Thanks for helping Engineer MCP. Read the boundary rules in `docs/integration.md` before you start. They keep the project focused and compatible. + +## Project layout + +| Path | Role | +| --- | --- | +| `src/engine/` | Pure calculation functions. | +| `src/units/` | Dimension-safe unit conversion. | +| `src/db/` | SQLite schema and seeding. | +| `src/handlers.ts` | Tool orchestration and result envelopes. | +| `data/` | Material, fastener, section, and reference data. | +| `tests/` | Deterministic test suite. | + +## Setup + +Install Node.js 22.13 or newer. + +Run `npm install` to install dependencies. + +## Checks + +Run these checks before you submit a change: + +1. Run `npm run typecheck`. +2. Run `npm test`. +3. Run `npm run build`. +4. Run `npm run demo`. + +The test suite is deterministic and offline. +It needs no API keys and no network access. + +## Conventions + +Use SI base units inside calculations. +Express every quantity with a unit string. +Keep the calculator logic separate from the MCP binding. +Return source references with every computed result. +Never invent references. Use only cited standards and texts. +Do not add comments unless they explain a non-obvious decision. + +## Data changes + +The data files in `data/` are the single source of truth. +The database seeds from these files on first start. +Do not edit the generated SQLite file directly. +Audit any new catalog values against a cited source. +Add a deterministic test that covers the new values. + +## Releases + +This server follows semantic versioning. +Adding a tool or a unit is a minor version bump. +Breaking a tool signature or the result envelope requires a major version bump. +Update the roadmap when you complete a planned item. diff --git a/README.md b/README.md index 96dfff5..5da5931 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Warnings surface when a method uses an approximation. | `section_catalog` | Published IPE, HEA, HEB, and UPN steel sections. | See [docs/mcp-tools.md](docs/mcp-tools.md) for the full reference. +See [docs/section-catalog.md](docs/section-catalog.md) for the covered range, the value provenance, and the data audit. ## Architecture @@ -218,9 +219,9 @@ References: ## Test status The test suite is deterministic and offline. -It covers the engines, the unit layer, the database, and the tools. +It covers the engines, the unit layer, the database, the tools, and the catalog data. -- 128 tests across 12 files. +- 135 tests across 13 files. - All tests pass on Node 22 and Node 24. - The CI workflow runs typecheck, tests, build, demo, and a package check. - The CI workflow verifies the CLI contract over standard output. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5caef44 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,37 @@ +# Security policy + +Engineer MCP stores no credentials and no user data. +The server reads only its own data files. + +## Supported versions + +Maintain fixes on the current release only. +Apply security fixes to the latest minor version. + +## Reporting a vulnerability + +Do not open a public issue for a security problem. +Send the details to the repository owner through a private channel. +Include the steps to reproduce the problem. +Include the impact of the problem. +Include any proposed fix. + +You will receive an acknowledgement within seven days. +We will work with you to confirm the problem. +We will release a fix for confirmed problems. +We will credit you in the release notes if you want credit. + +## Scope + +The following are in scope: + +- Server code in `src/`. +- The command line entry point. +- The MCP tool inputs and outputs. +- The packaged dependencies. + +The following are out of scope: + +- Typo-level issues with no security impact. +- Issues that need physical access to the host. +- Issues that rely on a compromised dependency channel. diff --git a/docs/integration.md b/docs/integration.md index 3e80fee..bb64ef9 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -28,7 +28,7 @@ Adopt these conventions to stay compatible. ## Data boundary -The material and fastener tables live in `data/`. The database seeds from these files on first start. Use the same JSON files as the single source of truth. Do not edit the generated SQLite file directly. +The material, fastener, and section tables live in `data/`. The database seeds from these files on first start. Use the same JSON files as the single source of truth. Do not edit the generated SQLite file directly. ## Versioning diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 4834db8..829755a 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -86,6 +86,9 @@ Inputs: The tool returns the published dimensions, mass, second moment of area, and section modulus of each match. Use it to find a designation, then pass that designation to `beam_bending` or `section_properties`. +The catalog covers IPE 80 to IPE 500, HEA 100 to HEA 300, HEB 100 to HEB 300, and UPN 80 to UPN 300. +See [section-catalog.md](section-catalog.md) for the full range, the value provenance, and the data audit. + Example: ```json diff --git a/docs/section-catalog.md b/docs/section-catalog.md new file mode 100644 index 0000000..9c0fa1e --- /dev/null +++ b/docs/section-catalog.md @@ -0,0 +1,73 @@ +# Standard section catalog + +The section catalog is a table of rolled steel sections. +It holds 50 sections in four series. +Each row stores the nominal dimensions, the mass, and the section properties. +The `section_catalog` tool searches this table. +The `beam_bending` and `section_properties` tools accept a designation from this table. + +## Covered range + +The catalog covers common sizes of each series. +It does not cover every size in the standard. + +| Series | Type | Sizes covered | +| --- | --- | --- | +| IPE | I profile, parallel flanges | IPE 80 to IPE 500 | +| HEA | H profile, wide flanges | HEA 100 to HEA 300 | +| HEB | H profile, wide flanges | HEB 100 to HEB 300 | +| UPN | U profile, tapered flanges | UPN 80 to UPN 300 | + +The designations follow the standard series. +IPE steps through 80, 100, 120, 140, 160, 180, 200, 220, 240, 270, 300, 330, 360, 400, 450, and 500. +The other series step through the even numbers in their range. + +## Source of the values + +The values come from the published tables of EN 10365. +See the entry `en-10365` in `data/references.json` for the full citation. +Every row stores the same reference id. +The `section_catalog` tool returns this reference with each result. + +Each property column has a defined source: + +| Column | Meaning | Source | +| --- | --- | --- | +| `heightMm` | Overall section height | Nominal dimension in the standard tables | +| `flangeWidthMm` | Overall flange width | Nominal dimension in the standard tables | +| `webThicknessMm` | Web thickness | Nominal dimension in the standard tables | +| `flangeThicknessMm` | Flange thickness | Nominal dimension in the standard tables | +| `areaCm2` | Cross-section area | Published value in the standard tables | +| `massPerMetreKgM` | Mass per unit length | Published value in the standard tables | +| `secondMomentCm4` | Second moment of area about the strong axis | Published value in the standard tables | +| `sectionModulusCm3` | Elastic section modulus about the strong axis | Published value in the standard tables | + +Fillets and root radii are included in the published values. +Do not compute the section properties from the nominal plate dimensions alone. + +## Audit + +Each value was checked against published tables for the standard series. +The audit also applies two consistency rules. + +First, the mass must match the area and the steel density. +The density is 7850 kilograms per cubic metre. +The check allows a tolerance of 0.5 percent. +The small deviation comes from the rounded values in the tables. + +Second, the section modulus must match the second moment of area. +The relation is `W = I divided by (h divided by 2)`. +The check allows a tolerance of 1 percent. +The small deviation comes from the rounded values in the tables. + +The test file `tests/section-data-audit.test.ts` runs these rules. +It also checks the covered range and the uniqueness of the designations. +Run `npm test` to reproduce the audit. + +## Data files + +The catalog lives in `data/sections.json`. +This file is the single source of truth. +The database seeds from it on first start. +Do not edit the generated SQLite file directly. +Use the same pattern for any new series. diff --git a/package-lock.json b/package-lock.json index edcef02..3503dba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@engineerkit/engineer-mcp", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", diff --git a/package.json b/package.json index 8cf90f2..c553661 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.3.0", + "version": "0.4.0", "description": "MCP server for verified mechanical-engineering calculations, references, and material data.", "type": "module", "license": "MIT", diff --git a/src/version.ts b/src/version.ts index 03714b1..71e5b81 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const VERSION = "0.3.0"; +export const VERSION = "0.4.0"; export const SERVER_NAME = "engineer-mcp"; diff --git a/tests/section-data-audit.test.ts b/tests/section-data-audit.test.ts new file mode 100644 index 0000000..cbd3ec7 --- /dev/null +++ b/tests/section-data-audit.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { loadSections, type SectionSeed } from "../src/assets.js"; + +const STEEL_DENSITY_KG_M3 = 7850; +const MASS_TOLERANCE = 0.005; +const MODULUS_TOLERANCE = 0.01; + +const EXPECTED_DESIGNATIONS: Record = { + IPE: [80, 100, 120, 140, 160, 180, 200, 220, 240, 270, 300, 330, 360, 400, 450, 500].map( + (size) => `IPE ${size}`, + ), + HEA: [100, 120, 140, 160, 180, 200, 220, 240, 260, 280, 300].map((size) => `HEA ${size}`), + HEB: [100, 120, 140, 160, 180, 200, 220, 240, 260, 280, 300].map((size) => `HEB ${size}`), + UPN: [80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280, 300].map((size) => `UPN ${size}`), +}; + +function sectionsBySeries(sections: SectionSeed[]): Map { + const bySeries = new Map(); + for (const section of sections) { + const list = bySeries.get(section.series) ?? []; + list.push(section); + bySeries.set(section.series, list); + } + return bySeries; +} + +describe("section catalog data audit", () => { + it("keeps every designation unique", () => { + const designations = loadSections().map((section) => section.designation); + expect(new Set(designations).size).toBe(designations.length); + }); + + it("covers exactly the documented series and sizes", () => { + const bySeries = sectionsBySeries(loadSections()); + expect([...bySeries.keys()].sort()).toEqual(["HEA", "HEB", "IPE", "UPN"]); + for (const [series, expected] of Object.entries(EXPECTED_DESIGNATIONS)) { + const designations = (bySeries.get(series) ?? []).map((section) => section.designation); + expect(designations).toEqual(expected); + } + }); + + it("orders each series by height", () => { + const bySeries = sectionsBySeries(loadSections()); + for (const list of bySeries.values()) { + for (let i = 1; i < list.length; i += 1) { + expect(list[i]?.heightMm).toBeGreaterThan(list[i - 1]?.heightMm ?? 0); + } + } + }); + + it("keeps the web thinner than the flange", () => { + for (const section of loadSections()) { + expect(section.webThicknessMm).toBeLessThanOrEqual(section.flangeThicknessMm); + } + }); + + it("keeps the mass consistent with the area and steel density", () => { + for (const section of loadSections()) { + const expectedMass = section.areaCm2 * 1e-4 * STEEL_DENSITY_KG_M3; + expect(Math.abs(section.massPerMetreKgM - expectedMass)).toBeLessThan(expectedMass * MASS_TOLERANCE); + } + }); + + it("keeps the section modulus consistent with the second moment of area", () => { + for (const section of loadSections()) { + const halfHeightM = section.heightMm / 2 * 1e-3; + const expectedModulusCm3 = (section.secondMomentCm4 * 1e-8) / halfHeightM * 1e6; + expect(Math.abs(section.sectionModulusCm3 - expectedModulusCm3)).toBeLessThan( + expectedModulusCm3 * MODULUS_TOLERANCE, + ); + } + }); + + it("keeps every dimension and property positive and finite", () => { + for (const section of loadSections()) { + const values = [ + section.heightMm, + section.flangeWidthMm, + section.webThicknessMm, + section.flangeThicknessMm, + section.areaCm2, + section.massPerMetreKgM, + section.secondMomentCm4, + section.sectionModulusCm3, + ]; + for (const value of values) { + expect(Number.isFinite(value)).toBe(true); + expect(value).toBeGreaterThan(0); + } + } + }); +}); From be928b12364b7e896112c48399ea1c6298bdc101 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:19:23 -0700 Subject: [PATCH 4/8] feat: extend engineer mcp --- .github/workflows/ci.yml | 1 + README.md | 24 +++- data/references.json | 2 +- docs/integration.md | 2 +- docs/mcp-tools.md | 42 +++++++ examples/demo.ts | 11 ++ package-lock.json | 4 +- package.json | 2 +- src/engine/fatigue.ts | 263 +++++++++++++++++++++++++++++++++++++++ src/engine/index.ts | 11 ++ src/handlers.ts | 29 +++++ src/schemas.ts | 44 +++++++ src/server.ts | 6 + src/version.ts | 2 +- tests/fatigue.test.ts | 148 ++++++++++++++++++++++ tests/tools.test.ts | 69 +++++++++- 16 files changed, 651 insertions(+), 9 deletions(-) create mode 100644 src/engine/fatigue.ts create mode 100644 tests/fatigue.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed68b94..11899a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,7 @@ jobs: node dist/index.js --list | grep -q "beam_bending" node dist/index.js --list | grep -q "section_catalog" node dist/index.js --list | grep -q "interference_fit" + node dist/index.js --list | grep -q "fatigue_analysis" - name: Verify the demo runs run: npm run demo diff --git a/README.md b/README.md index 5da5931..5e182cb 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ The release covers these domains: - Shaft torsion and first critical speed. - Bearing rating life to ISO 281. - von Mises equivalent stress. +- Fatigue analysis for cyclic loads. - Cross-section properties. - Press and shrink fit analysis by Lamé theory. - Standard steel section catalog to EN 10365. @@ -55,6 +56,7 @@ Warnings surface when a method uses an approximation. | `shaft_analysis` | Torsion stress, twist, and critical speed. | | `bearing_life` | ISO 281 rating life in revolutions and hours. | | `von_mises` | Equivalent stress and yield safety factor. | +| `fatigue_analysis` | Endurance limit and fatigue safety factor for cyclic loads. | | `unit_convert` | Conversion between compatible units. | | `material_lookup` | Curated mechanical properties of materials. | | `section_catalog` | Published IPE, HEA, HEB, and UPN steel sections. | @@ -171,6 +173,19 @@ References: - Theory of Elasticity (Lamé solution for thick-walled cylinders) ``` +A call to `fatigue_analysis` for a ground steel part at 90% reliability with a 120 MPa alternating stress on an 80 MPa mean stress: + +```text +Endurance limit 280.5 MPa +Static yield safety factor 2.9 +Fatigue safety factor 1.839 + +Method: Fatigue analysis by endurance limit and mean-stress criterion +Formula: Se' = 0.5 Sut for steel, Se = ka kb kc kd ke kf Se', 1/n = sigma_a/Se + sigma_m/Sut +References: + - Shigley's Mechanical Engineering Design (Tenth edition, 2015) +``` + A call to `unit_convert` with a torque-to-energy request fails safely: ```text @@ -221,7 +236,7 @@ References: The test suite is deterministic and offline. It covers the engines, the unit layer, the database, the tools, and the catalog data. -- 135 tests across 13 files. +- 159 tests across 14 files. - All tests pass on Node 22 and Node 24. - The CI workflow runs typecheck, tests, build, demo, and a package check. - The CI workflow verifies the CLI contract over standard output. @@ -237,6 +252,10 @@ Run `npm test` to reproduce the results. - The critical speed is a first-mode approximation. - The spring design covers static round-wire springs only. It does not estimate fatigue life for cyclic loads. + Use the `fatigue_analysis` tool for a separate cyclic-load check. +- The fatigue analysis estimates the endurance limit for steel only. + The tool applies to infinite-life design and does not model finite-life crack growth. + Surface and reliability factors follow the standard table values. - The press-fit theory assumes elastic material behavior and uniform friction. It does not model residual stress after yield. - The section catalog covers common IPE, HEA, HEB, and UPN sizes. @@ -259,10 +278,11 @@ Each release stays useful on its own. The `beam_bending` and `section_properties` tools accept a catalog designation. - Press and shrink fit analysis. The `interference_fit` tool reports the interface pressure, the hoop stresses, and the friction capacity. +- Fatigue analysis. + The `fatigue_analysis` tool estimates the endurance limit for steel and reports the fatigue safety factor for a selected mean-stress criterion. ### Remaining -- Add fatigue analysis for cyclic loads. - Add more unit categories, including viscosity and thermal conductivity. - Add HTTP transport. diff --git a/data/references.json b/data/references.json index 62be926..224c937 100644 --- a/data/references.json +++ b/data/references.json @@ -3,7 +3,7 @@ "title": "Shigley's Mechanical Engineering Design", "source": "McGraw-Hill Education", "edition": "Tenth edition, 2015", - "section": "Chapters 3, 5, 8, 11, and 12", + "section": "Chapters 3, 5, 6, 8, 11, and 12", "note": "Standard reference for strength, fatigue, fasteners, and bearings." }, "roark-2011": { diff --git a/docs/integration.md b/docs/integration.md index bb64ef9..5d69af7 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -43,10 +43,10 @@ Engineer MCP grows in independent releases. Each release stays useful on its own - Helical compression spring design. The `spring_design` tool computes the spring rate, the shear stress, and the safety factor. - Standard section catalog. The `section_catalog` tool searches published IPE, HEA, HEB, and UPN sections. The `beam_bending` and `section_properties` tools accept a standard designation. - Press and shrink fit analysis. The `interference_fit` tool computes the interface pressure, the hoop stresses, and the friction capacity. +- Fatigue analysis. The `fatigue_analysis` tool estimates the endurance limit for steel and computes the fatigue safety factor for a mean-stress criterion. ### Remaining -- Add fatigue analysis for cyclic loads. - Add more unit categories, including viscosity and thermal conductivity. - Add HTTP transport in addition to stdio. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 829755a..0486d5c 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -180,6 +180,48 @@ Inputs: - Cartesian mode uses `sigmaX`, `sigmaY`, `sigmaZ`, `tauXY`, `tauXZ`, `tauYZ`. - `yieldStrength`: enables the safety factor. +## fatigue_analysis + +Compute the endurance limit and the fatigue safety factor for cyclic loading. + +The tool follows the modified Marin method for steel. It estimates the endurance limit from the ultimate strength and the surface, size, load, temperature, reliability, and miscellaneous correction factors. Pass `enduranceLimit` to skip the estimate and use a measured or known value. + +The safety factor follows one of four mean-stress criteria: + +- `modified_goodman`: uses the ultimate strength. +- `soderberg`: uses the yield strength. +- `gerber`: uses the ultimate strength. +- `asme_elliptic`: uses the yield strength. + +Inputs: + +- `ultimateStrength`: ultimate tensile strength in pascals. +- `yieldStrength`: tensile yield strength in pascals. Required for the `soderberg` and `asme_elliptic` criteria. +- `meanStress`: mean stress in pascals. +- `alternatingStress`: alternating stress amplitude in pascals. +- `criterion`: one of the four criteria. The default is `modified_goodman`. +- `enduranceLimit`: fully corrected endurance limit in pascals. Optional. +- `surfaceFinish`: `ground`, `machined`, `cold_drawn`, `hot_rolled`, or `as_forged`. The default is `machined`. +- `sizeFactor`, `loadFactor`, `temperatureFactor`, `miscellaneousFactor`: Marin correction factors. Each defaults to `1`. +- `reliabilityFactor`: reliability factor. The default is `1`. +- `reliability`: reliability in percent from 50 to 99.99. Sets the reliability factor from the standard table. +- `outputUnits`: optional unit overrides. + +The tool warns when the static yield check governs the design. It warns when the fatigue safety factor falls below unity. + +Example: + +```json +{ + "ultimateStrength": 690000000, + "yieldStrength": 580000000, + "meanStress": 80000000, + "alternatingStress": 120000000, + "surfaceFinish": "ground", + "reliability": 90 +} +``` + ## unit_convert Convert a value between two units. diff --git a/examples/demo.ts b/examples/demo.ts index 835b109..2216605 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -81,6 +81,7 @@ type ToolHandlers = { shaft_analysis: Handler; bearing_life: Handler; von_mises: Handler; + fatigue_analysis: Handler; unit_convert: Handler; material_lookup: Handler; section_catalog: Handler; @@ -100,6 +101,7 @@ async function main(): Promise { ["shaft_analysis", toolHandlers.shaft_analysis], ["bearing_life", toolHandlers.bearing_life], ["von_mises", toolHandlers.von_mises], + ["fatigue_analysis", toolHandlers.fatigue_analysis], ["unit_convert", toolHandlers.unit_convert], ["unit_convert (torque to energy)", toolHandlers.unit_convert], ["material_lookup", toolHandlers.material_lookup], @@ -160,6 +162,15 @@ async function main(): Promise { yieldStrength: 355e6, outputUnits: { vonMisesStress: "MPa", maxShearStress: "MPa" }, }, + { + ultimateStrength: 690e6, + yieldStrength: 580e6, + meanStress: 80e6, + alternatingStress: 120e6, + surfaceFinish: "ground", + reliability: 90, + outputUnits: { enduranceLimit: "MPa" }, + }, { value: 1000, from: "psi", diff --git a/package-lock.json b/package-lock.json index 3503dba..b9fc9ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@engineerkit/engineer-mcp", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", diff --git a/package.json b/package.json index c553661..5d320d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.4.0", + "version": "0.5.0", "description": "MCP server for verified mechanical-engineering calculations, references, and material data.", "type": "module", "license": "MIT", diff --git a/src/engine/fatigue.ts b/src/engine/fatigue.ts new file mode 100644 index 0000000..50a5e8f --- /dev/null +++ b/src/engine/fatigue.ts @@ -0,0 +1,263 @@ +import type { Computation, MethodRecord, Quantity } from "../types.js"; + +export type SurfaceFinish = "ground" | "machined" | "cold_drawn" | "hot_rolled" | "as_forged"; + +export type FatigueCriterion = "modified_goodman" | "soderberg" | "gerber" | "asme_elliptic"; + +export type EnduranceLimitInput = { + ultimateStrength: number; + enduranceLimit?: number; + surfaceFinish?: SurfaceFinish; + sizeFactor?: number; + loadFactor?: number; + temperatureFactor?: number; + reliabilityFactor?: number; + reliability?: number; + miscellaneousFactor?: number; +}; + +export type FatigueInput = EnduranceLimitInput & { + yieldStrength?: number; + meanStress: number; + alternatingStress: number; + criterion?: FatigueCriterion; +}; + +export const FATIGUE_METHOD: MethodRecord = { + id: "fatigue-analysis", + name: "Fatigue analysis by endurance limit and mean-stress criterion", + formula: + "Se' = 0.5 Sut for steel, Se = ka kb kc kd ke kf Se', 1/n = sigma_a/Se + sigma_m/Sut", + notes: + "The endurance limit follows the modified Marin method for steel. The safety factor follows the selected mean-stress fatigue criterion. The tool warns when the static yield check governs.", + referenceIds: ["shigley-2015"], +}; + +const SURFACE_COEFFICIENTS: Record = { + ground: { a: 1.58, b: -0.085 }, + machined: { a: 4.51, b: -0.265 }, + cold_drawn: { a: 4.51, b: -0.265 }, + hot_rolled: { a: 57.7, b: -0.718 }, + as_forged: { a: 272, b: -0.995 }, +}; + +const RELIABILITY_FACTORS: Array<{ reliability: number; factor: number }> = [ + { reliability: 50, factor: 1 }, + { reliability: 90, factor: 0.897 }, + { reliability: 95, factor: 0.868 }, + { reliability: 99, factor: 0.814 }, + { reliability: 99.9, factor: 0.753 }, + { reliability: 99.99, factor: 0.702 }, +]; + +const UNLIMITED_ENDURANCE_SUT = 1400e6; +const UNLIMITED_ENDURANCE_LIMIT = 700e6; + +export function surfaceFactor(finish: SurfaceFinish, ultimateStrengthPa: number): number { + if (!(ultimateStrengthPa > 0)) { + throw new Error("ultimateStrength must be positive."); + } + const { a, b } = SURFACE_COEFFICIENTS[finish]; + return a * (ultimateStrengthPa / 1e6) ** b; +} + +export function reliabilityFactor(reliability: number): number { + if (reliability < 50 || reliability > 99.99) { + throw new Error("reliability must be between 50 and 99.99 percent."); + } + const first = RELIABILITY_FACTORS[0]; + const last = RELIABILITY_FACTORS[RELIABILITY_FACTORS.length - 1]; + if (!first || !last) { + return 1; + } + let lower = first; + let upper = last; + for (const entry of RELIABILITY_FACTORS) { + if (entry.reliability <= reliability) { + lower = entry; + } + if (entry.reliability >= reliability) { + upper = entry; + break; + } + } + if (lower === upper) { + return lower.factor; + } + const t = (reliability - lower.reliability) / (upper.reliability - lower.reliability); + return lower.factor + t * (upper.factor - lower.factor); +} + +export function enduranceLimit(input: EnduranceLimitInput): number { + if (input.enduranceLimit !== undefined) { + if (!(input.enduranceLimit > 0)) { + throw new Error("enduranceLimit must be positive."); + } + return input.enduranceLimit; + } + const sut = input.ultimateStrength; + if (!(sut > 0)) { + throw new Error("ultimateStrength must be positive."); + } + const testLimit = sut <= UNLIMITED_ENDURANCE_SUT ? 0.5 * sut : UNLIMITED_ENDURANCE_LIMIT; + const finish = input.surfaceFinish ?? "machined"; + const kb = input.sizeFactor ?? 1; + const kc = input.loadFactor ?? 1; + const kd = input.temperatureFactor ?? 1; + const ke = + input.reliabilityFactor !== undefined + ? input.reliabilityFactor + : input.reliability !== undefined + ? reliabilityFactor(input.reliability) + : 1; + const kf = input.miscellaneousFactor ?? 1; + return testLimit * surfaceFactor(finish, sut) * kb * kc * kd * ke * kf; +} + +function criterionSafetyFactor(input: FatigueInput, se: number): number { + const sa = input.alternatingStress; + const sm = input.meanStress; + const sut = input.ultimateStrength; + const criterion = input.criterion ?? "modified_goodman"; + + switch (criterion) { + case "modified_goodman": + if (sm >= sut) { + throw new Error( + "The mean stress reaches the ultimate strength. The modified Goodman criterion has no positive safety factor.", + ); + } + return 1 / (sa / se + sm / sut); + case "soderberg": { + const sy = input.yieldStrength; + if (sy === undefined) { + throw new Error("The soderberg criterion requires yieldStrength."); + } + if (sm >= sy) { + throw new Error("The mean stress reaches the yield strength. The soderberg criterion has no positive safety factor."); + } + return 1 / (sa / se + sm / sy); + } + case "gerber": { + const a = sm / sut; + const b = sa / se; + if (a === 0) { + return b === 0 ? Infinity : 1 / b; + } + return (-b + Math.sqrt(b * b + 4 * a * a)) / (2 * a * a); + } + case "asme_elliptic": { + const sy = input.yieldStrength; + if (sy === undefined) { + throw new Error("The asme_elliptic criterion requires yieldStrength."); + } + const denominator = Math.sqrt((sa / se) ** 2 + (sm / sy) ** 2); + return denominator === 0 ? Infinity : 1 / denominator; + } + } +} + +export function analyzeFatigue(input: FatigueInput): Computation { + if (!(input.ultimateStrength > 0)) { + throw new Error("ultimateStrength must be positive."); + } + if (!(input.meanStress >= 0)) { + throw new Error("meanStress must be zero or positive."); + } + if (!(input.alternatingStress >= 0)) { + throw new Error("alternatingStress must be zero or positive."); + } + if (input.meanStress === 0 && input.alternatingStress === 0) { + throw new Error("meanStress and alternatingStress cannot both be zero."); + } + + const criterion = input.criterion ?? "modified_goodman"; + const se = enduranceLimit(input); + const factor = criterionSafetyFactor(input, se); + + const warnings: string[] = []; + if (input.enduranceLimit !== undefined) { + const corrections = + input.surfaceFinish !== undefined || + input.sizeFactor !== undefined || + input.loadFactor !== undefined || + input.temperatureFactor !== undefined || + input.reliabilityFactor !== undefined || + input.reliability !== undefined || + input.miscellaneousFactor !== undefined; + if (corrections) { + warnings.push("enduranceLimit overrides the Marin estimate. The surface and reliability corrections are ignored."); + } + } + + const maxStress = input.alternatingStress + input.meanStress; + let staticYieldSafetyFactor: number | undefined; + if (input.yieldStrength !== undefined && maxStress > 0) { + staticYieldSafetyFactor = input.yieldStrength / maxStress; + if (staticYieldSafetyFactor < factor) { + warnings.push( + "The static yield check governs the design. The maximum combined stress reaches yield before the fatigue criterion. Use the soderberg or asme_elliptic criterion, or increase the section.", + ); + } + } + + const endurable = Number.isFinite(factor) && factor > 1; + if (!endurable && factor > 0) { + warnings.push("The fatigue safety factor is below 1. The part does not meet the infinite-life requirement."); + } + + const quantityEndurance: Quantity = { + key: "enduranceLimit", + label: "Endurance limit", + value: se, + unit: "Pa", + description: + input.enduranceLimit !== undefined + ? "Fully corrected endurance limit Se provided by the caller." + : "Fully corrected endurance limit Se from the modified Marin method for steel.", + }; + const quantityStatic: Quantity | undefined = + staticYieldSafetyFactor !== undefined + ? { + key: "staticYieldSafetyFactor", + label: "Static yield safety factor", + value: staticYieldSafetyFactor, + unit: "", + description: "Yield strength divided by the maximum combined stress.", + } + : undefined; + + const quantities: Quantity[] = [quantityEndurance]; + if (quantityStatic) { + quantities.push(quantityStatic); + } + + return { + method: FATIGUE_METHOD, + inputs: { + ultimateStrength: input.ultimateStrength, + yieldStrength: input.yieldStrength, + meanStress: input.meanStress, + alternatingStress: input.alternatingStress, + criterion, + enduranceLimit: input.enduranceLimit, + surfaceFinish: input.surfaceFinish, + sizeFactor: input.sizeFactor, + loadFactor: input.loadFactor, + temperatureFactor: input.temperatureFactor, + reliabilityFactor: input.reliabilityFactor, + reliability: input.reliability, + miscellaneousFactor: input.miscellaneousFactor, + }, + quantities, + safetyFactor: { + key: "fatigueSafetyFactor", + label: "Fatigue safety factor", + value: factor, + unit: "", + description: `Safety factor for the ${criterion.replaceAll("_", " ")} mean-stress criterion.`, + }, + referenceIds: FATIGUE_METHOD.referenceIds, + warnings, + }; +} diff --git a/src/engine/index.ts b/src/engine/index.ts index 1c358eb..c4c2845 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -1,6 +1,17 @@ export { analyzeBeam, BEAM_METHOD, type BeamInput, type BeamLoad, type BeamSupport } from "./beam.js"; export { analyzeBearing, BEARING_METHOD, equivalentLoad, type BearingInput, type BearingType } from "./bearing.js"; export { analyzeBolt, BOLT_METHOD, tensileStressArea, type BoltGradeData, type BoltInput } from "./bolt.js"; +export { + analyzeFatigue, + enduranceLimit, + FATIGUE_METHOD, + reliabilityFactor, + surfaceFactor, + type FatigueCriterion, + type FatigueInput, + type EnduranceLimitInput, + type SurfaceFinish, +} from "./fatigue.js"; export { analyzePressFit, PRESS_FIT_METHOD, type FitInput } from "./fit.js"; export { computeSection, SECTION_METHOD, type SectionDef, type SectionProperties } from "./sections.js"; export { analyzeShaft, SHAFT_METHOD, type ShaftInput } from "./shaft.js"; diff --git a/src/handlers.ts b/src/handlers.ts index 4b97924..aa916cb 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -3,13 +3,16 @@ import { analyzeBeam, analyzeBearing, analyzeBolt, + analyzeFatigue, analyzePressFit, analyzeShaft, analyzeSpring, computeSection, vonMises, + type FatigueCriterion, type SectionDef, type SpringEndType, + type SurfaceFinish, } from "./engine/index.js"; import type { Computation, MethodRecord, Quantity, ReferenceRecord, ToolFailure, ToolResponse, ToolResult } from "./types.js"; import type { UnitOutcome } from "./units/index.js"; @@ -402,6 +405,31 @@ function shaftHandler(ctx: AppContext): Handler { }; } +function fatigueHandler(ctx: AppContext): Handler { + return (input) => { + try { + const computation = analyzeFatigue({ + ultimateStrength: input.ultimateStrength as number, + yieldStrength: input.yieldStrength as number | undefined, + meanStress: input.meanStress as number, + alternatingStress: input.alternatingStress as number, + criterion: input.criterion as FatigueCriterion | undefined, + enduranceLimit: input.enduranceLimit as number | undefined, + surfaceFinish: input.surfaceFinish as SurfaceFinish | undefined, + sizeFactor: input.sizeFactor as number | undefined, + loadFactor: input.loadFactor as number | undefined, + temperatureFactor: input.temperatureFactor as number | undefined, + reliabilityFactor: input.reliabilityFactor as number | undefined, + reliability: input.reliability as number | undefined, + miscellaneousFactor: input.miscellaneousFactor as number | undefined, + }); + return buildResult(ctx, "fatigue_analysis", computation, input.outputUnits as Record | undefined); + } catch (error) { + return failure("fatigue_analysis", error instanceof Error ? error.message : String(error), input); + } + }; +} + function springHandler(ctx: AppContext): Handler { return (input) => { try { @@ -614,6 +642,7 @@ export function createHandlers(ctx: AppContext): Record { spring_design: springHandler(ctx), bearing_life: bearingHandler(ctx), von_mises: stressHandler(ctx), + fatigue_analysis: fatigueHandler(ctx), unit_convert: unitConvertHandler(ctx), material_lookup: materialHandler(ctx), section_catalog: sectionCatalogHandler(ctx), diff --git a/src/schemas.ts b/src/schemas.ts index 2e65055..c2f6290 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -124,6 +124,49 @@ export const stressSchema = z.object({ yieldStrength: z.number().positive().optional().describe("Tensile yield strength in pascals. Enables the safety factor."), }); +export const fatigueSchema = z.object({ + ultimateStrength: z + .number() + .positive() + .describe("Ultimate tensile strength Sut in pascals. Used to estimate the endurance limit."), + yieldStrength: z + .number() + .positive() + .optional() + .describe("Tensile yield strength Sy in pascals. Required for the soderberg and asme_elliptic criteria."), + meanStress: z.number().min(0).describe("Mean stress sigma_m in pascals."), + alternatingStress: z.number().min(0).describe("Alternating stress amplitude sigma_a in pascals."), + criterion: z + .enum(["modified_goodman", "soderberg", "gerber", "asme_elliptic"]) + .optional() + .describe("Mean-stress fatigue criterion. Defaults to modified_goodman."), + enduranceLimit: z + .number() + .positive() + .optional() + .describe("Fully corrected endurance limit Se in pascals. Provide it to skip the Marin estimate."), + surfaceFinish: z + .enum(["ground", "machined", "cold_drawn", "hot_rolled", "as_forged"]) + .optional() + .describe("Surface finish for the Marin surface factor. Defaults to machined."), + sizeFactor: z.number().positive().optional().describe("Marin size factor kb. Defaults to 1."), + loadFactor: z + .number() + .positive() + .optional() + .describe("Marin load factor kc. Bending 1, axial 0.85, torsion 0.59. Defaults to 1."), + temperatureFactor: z.number().positive().optional().describe("Marin temperature factor kd. Defaults to 1."), + reliabilityFactor: z.number().positive().optional().describe("Marin reliability factor ke. Defaults to 1."), + reliability: z + .number() + .min(50) + .max(99.99) + .optional() + .describe("Reliability in percent. Sets ke from the standard table. Overridden by reliabilityFactor."), + miscellaneousFactor: z.number().positive().optional().describe("Marin miscellaneous factor kf. Defaults to 1."), + outputUnits, +}); + export const fitSchema = z.object({ interfaceRadius: z.number().positive().describe("Interface radius of the fit in metres."), hubOuterRadius: z.number().positive().describe("Outer radius of the hub in metres."), @@ -164,6 +207,7 @@ export type SpringInput = z.infer; export type BearingInput = z.infer; export type SectionPropsInput = z.infer; export type StressInput = z.infer; +export type FatigueInput = z.infer; export type UnitConvertInput = z.infer; export type MaterialInput = z.infer; export type FitInput = z.infer; diff --git a/src/server.ts b/src/server.ts index b36ffe4..119669e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,7 @@ import { beamSchema, bearingSchema, boltSchema, + fatigueSchema, fitSchema, materialSchema, sectionCatalogSchema, @@ -59,6 +60,11 @@ const TOOL_SCHEMAS: Record = { description: "von Mises equivalent stress, maximum shear stress, and yield safety factor for a stress state.", schema: stressSchema, }, + fatigue_analysis: { + description: + "Endurance limit and fatigue safety factor for cyclic loading. Uses the modified Marin method for steel and a mean-stress criterion.", + schema: fatigueSchema, + }, unit_convert: { description: "Convert a value between compatible units. Rejects mismatched dimensions and quantity categories.", schema: unitConvertSchema, diff --git a/src/version.ts b/src/version.ts index 71e5b81..fc2871b 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const VERSION = "0.4.0"; +export const VERSION = "0.5.0"; export const SERVER_NAME = "engineer-mcp"; diff --git a/tests/fatigue.test.ts b/tests/fatigue.test.ts new file mode 100644 index 0000000..da3ab60 --- /dev/null +++ b/tests/fatigue.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeFatigue, + enduranceLimit, + reliabilityFactor, + surfaceFactor, +} from "../src/engine/fatigue.js"; + +describe("Marin endurance limit for steel", () => { + it("estimates the surface factor for a machined part", () => { + expect(surfaceFactor("machined", 690e6)).toBeCloseTo(0.798, 3); + }); + + it("estimates the surface factor for a ground part", () => { + expect(surfaceFactor("ground", 690e6)).toBeCloseTo(0.906, 3); + }); + + it("caps the test endurance limit for very strong steel", () => { + const se = enduranceLimit({ + ultimateStrength: 1600e6, + surfaceFinish: "ground", + }); + expect(se).toBeCloseTo(700e6 * surfaceFactor("ground", 1600e6), 0); + }); + + it("applies the reliability table values", () => { + expect(reliabilityFactor(50)).toBeCloseTo(1, 6); + expect(reliabilityFactor(90)).toBeCloseTo(0.897, 6); + expect(reliabilityFactor(99.9)).toBeCloseTo(0.753, 6); + }); + + it("interpolates the reliability factor between table rows", () => { + expect(reliabilityFactor(99.95)).toBeCloseTo(0.725, 3); + }); + + it("rejects a reliability outside the table range", () => { + expect(() => reliabilityFactor(99.999)).toThrow(/between 50 and 99\.99/); + }); + + it("honors an explicit endurance limit override", () => { + const se = enduranceLimit({ ultimateStrength: 690e6, enduranceLimit: 250e6 }); + expect(se).toBe(250e6); + }); +}); + +describe("fatigue safety factors", () => { + const base = { + ultimateStrength: 800e6, + yieldStrength: 450e6, + enduranceLimit: 200e6, + meanStress: 100e6, + alternatingStress: 100e6, + }; + + it("computes the modified Goodman safety factor", () => { + const result = analyzeFatigue({ ...base }); + expect(result.safetyFactor?.value).toBeCloseTo(1.6, 6); + }); + + it("computes the Gerber safety factor", () => { + const result = analyzeFatigue({ ...base, criterion: "gerber" }); + expect(result.safetyFactor?.value).toBeCloseTo(1.8885, 3); + }); + + it("computes the Soderberg safety factor", () => { + const result = analyzeFatigue({ ...base, criterion: "soderberg" }); + expect(result.safetyFactor?.value).toBeCloseTo(1.3846, 3); + }); + + it("computes the ASME-elliptic safety factor", () => { + const result = analyzeFatigue({ ...base, criterion: "asme_elliptic" }); + expect(result.safetyFactor?.value).toBeCloseTo(1.8276, 3); + }); + + it("defaults to the modified Goodman criterion", () => { + const result = analyzeFatigue({ ...base }); + expect(result.method.id).toBe("fatigue-analysis"); + expect(result.safetyFactor?.description).toContain("modified goodman"); + }); + + it("handles a purely alternating stress", () => { + const result = analyzeFatigue({ ...base, meanStress: 0 }); + expect(result.safetyFactor?.value).toBeCloseTo(2, 6); + }); + + it("handles a purely steady stress", () => { + const result = analyzeFatigue({ ...base, alternatingStress: 0 }); + expect(result.safetyFactor?.value).toBeCloseTo(8, 6); + }); + + it("requires yieldStrength for the soderberg criterion", () => { + expect(() => + analyzeFatigue({ + ultimateStrength: 800e6, + meanStress: 100e6, + alternatingStress: 100e6, + criterion: "soderberg", + }), + ).toThrow(/requires yieldStrength/); + }); + + it("rejects zero stress on both axes", () => { + expect(() => + analyzeFatigue({ + ultimateStrength: 800e6, + meanStress: 0, + alternatingStress: 0, + }), + ).toThrow(/cannot both be zero/); + }); + + it("rejects a mean stress at the ultimate strength for Goodman", () => { + expect(() => + analyzeFatigue({ + ultimateStrength: 800e6, + meanStress: 800e6, + alternatingStress: 10e6, + }), + ).toThrow(/no positive safety factor/); + }); + + it("warns when the static yield check governs", () => { + const result = analyzeFatigue({ + ultimateStrength: 800e6, + yieldStrength: 150e6, + meanStress: 100e6, + alternatingStress: 100e6, + }); + expect(result.warnings.some((w) => w.includes("static yield check"))).toBe(true); + }); + + it("warns when the safety factor is below unity", () => { + const result = analyzeFatigue({ + ultimateStrength: 800e6, + meanStress: 500e6, + alternatingStress: 500e6, + }); + expect(result.safetyFactor?.value).toBeLessThan(1); + expect(result.warnings.some((w) => w.includes("below 1"))).toBe(true); + }); + + it("reports the endurance limit quantity", () => { + const result = analyzeFatigue({ ...base }); + const se = result.quantities.find((q) => q.key === "enduranceLimit"); + expect(se?.value).toBe(200e6); + expect(se?.unit).toBe("Pa"); + }); +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index adfd946..34a4843 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -14,6 +14,7 @@ type Handlers = { shaft_analysis: Handler; bearing_life: Handler; von_mises: Handler; + fatigue_analysis: Handler; unit_convert: Handler; material_lookup: Handler; section_catalog: Handler; @@ -33,7 +34,7 @@ function expectOk(response: Awaited>): ToolResult { } describe("tool registry", () => { - it("registers all eleven tools", () => { + it("registers all twelve tools", () => { expect(listTools().sort()).toEqual( [ "beam_bending", @@ -44,6 +45,7 @@ describe("tool registry", () => { "shaft_analysis", "bearing_life", "von_mises", + "fatigue_analysis", "unit_convert", "material_lookup", "section_catalog", @@ -331,6 +333,71 @@ describe("von_mises tool", () => { }); }); +describe("fatigue_analysis tool", () => { + it("returns an envelope with an endurance limit and a safety factor", () => { + setup(); + const response = handlers.fatigue_analysis({ + ultimateStrength: 690e6, + yieldStrength: 580e6, + meanStress: 80e6, + alternatingStress: 120e6, + surfaceFinish: "ground", + reliability: 90, + outputUnits: { enduranceLimit: "MPa" }, + }); + const result = expectOk(response); + + expect(result.tool).toBe("fatigue_analysis"); + expect(result.method.id).toBe("fatigue-analysis"); + expect(result.references.map((r) => r.id)).toContain("shigley-2015"); + expect(result.safetyFactor).toBeDefined(); + expect(result.safetyFactor?.value).toBeGreaterThan(0); + + const se = result.quantities.find((q) => q.key === "enduranceLimit"); + expect(se?.unit).toBe("MPa"); + expect(se?.value).toBeGreaterThan(100); + }); + + it("uses an explicit endurance limit and converts its unit", () => { + setup(); + const response = handlers.fatigue_analysis({ + ultimateStrength: 800e6, + yieldStrength: 450e6, + enduranceLimit: 200e6, + meanStress: 100e6, + alternatingStress: 100e6, + outputUnits: { enduranceLimit: "MPa" }, + }); + const result = expectOk(response); + expect(result.safetyFactor?.value).toBeCloseTo(1.6, 5); + expect(result.quantities.find((q) => q.key === "enduranceLimit")?.unit).toBe("MPa"); + }); + + it("rejects a criterion that lacks a yield strength", () => { + setup(); + const response = handlers.fatigue_analysis({ + ultimateStrength: 800e6, + meanStress: 100e6, + alternatingStress: 100e6, + criterion: "soderberg", + }); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toContain("yieldStrength"); + } + }); + + it("rejects a negative alternating stress", () => { + setup(); + const response = handlers.fatigue_analysis({ + ultimateStrength: 800e6, + meanStress: 100e6, + alternatingStress: -5, + }); + expect(response.ok).toBe(false); + }); +}); + describe("unit_convert tool", () => { it("converts and reports the factor", () => { setup(); From a14f6ffc4dcd28f6e8eafe75325d25884f7225c5 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:47:30 -0700 Subject: [PATCH 5/8] feat: extend engineer mcp --- README.md | 29 ++++++++-- docs/integration.md | 2 +- docs/mcp-tools.md | 15 ++++++ docs/units.md | 91 +++++++++++++++++++++++++++++++ examples/demo.ts | 18 +++++++ package-lock.json | 4 +- package.json | 2 +- src/units/dimensions.ts | 3 ++ src/units/registry.ts | 22 ++++++++ src/version.ts | 2 +- tests/units.test.ts | 116 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 docs/units.md diff --git a/README.md b/README.md index 5e182cb..10f23ec 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ The release covers these domains: - Cross-section properties. - Press and shrink fit analysis by Lamé theory. - Standard steel section catalog to EN 10365. -- Dimension-safe unit conversion. +- Dimension-safe unit conversion, including viscosity and thermal conductivity. - Material property lookup. ## How results stay trustworthy @@ -63,6 +63,7 @@ Warnings surface when a method uses an approximation. See [docs/mcp-tools.md](docs/mcp-tools.md) for the full reference. See [docs/section-catalog.md](docs/section-catalog.md) for the covered range, the value provenance, and the data audit. +See [docs/units.md](docs/units.md) for the unit model and the full category list. ## Architecture @@ -193,6 +194,24 @@ Error: Category mismatch: N·m is torque, J is energy. Use a unit of the same quantity. ``` +A call to `unit_convert` for a 100 cP lubricant converts to the SI unit: + +```text +Converted value 0.1 Pa·s + Value of 100 cP expressed in Pa·s. + Value of 100 cP in the SI base unit Pa·s. +Factor: 0.001 (dynamic viscosity) +``` + +A call to `unit_convert` for copper with 401 W/(m·K) converts to the imperial unit: + +```text +Converted value 231.7 BTU/(ft·h·°F) + Value of 401 W/(m·K) expressed in BTU/(ft·h·°F). + Value of 401 W/(m·K) in the SI base unit W/(m·K). +Factor: 1 (thermal conductivity) +``` + A call to `section_catalog` for the HEB series returns the published sections: ```text @@ -236,7 +255,7 @@ References: The test suite is deterministic and offline. It covers the engines, the unit layer, the database, the tools, and the catalog data. -- 159 tests across 14 files. +- 172 tests across 14 files. - All tests pass on Node 22 and Node 24. - The CI workflow runs typecheck, tests, build, demo, and a package check. - The CI workflow verifies the CLI contract over standard output. @@ -260,6 +279,8 @@ Run `npm test` to reproduce the results. It does not model residual stress after yield. - The section catalog covers common IPE, HEA, HEB, and UPN sizes. It does not include every size in the standard. +- The viscosity and thermal conductivity units cover common engineering units. + They do not cover every named unit in older texts. - The built-in SQLite module of Node.js is still experimental. Check the cited sources for exact values. @@ -280,10 +301,12 @@ Each release stays useful on its own. The `interference_fit` tool reports the interface pressure, the hoop stresses, and the friction capacity. - Fatigue analysis. The `fatigue_analysis` tool estimates the endurance limit for steel and reports the fatigue safety factor for a selected mean-stress criterion. +- Viscosity and thermal conductivity units. + The `unit_convert` tool converts dynamic viscosity, kinematic viscosity, and thermal conductivity. + The registry covers centipoise, centistokes, and the imperial conductivity units. ### Remaining -- Add more unit categories, including viscosity and thermal conductivity. - Add HTTP transport. See [docs/integration.md](docs/integration.md) for the EngineerKit plan. diff --git a/docs/integration.md b/docs/integration.md index 5d69af7..2d58e9c 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -44,10 +44,10 @@ Engineer MCP grows in independent releases. Each release stays useful on its own - Standard section catalog. The `section_catalog` tool searches published IPE, HEA, HEB, and UPN sections. The `beam_bending` and `section_properties` tools accept a standard designation. - Press and shrink fit analysis. The `interference_fit` tool computes the interface pressure, the hoop stresses, and the friction capacity. - Fatigue analysis. The `fatigue_analysis` tool estimates the endurance limit for steel and computes the fatigue safety factor for a mean-stress criterion. +- Viscosity and thermal conductivity units. The unit registry covers dynamic viscosity, kinematic viscosity, and thermal conductivity. The `unit_convert` tool converts between the common engineering units of each. ### Remaining -- Add more unit categories, including viscosity and thermal conductivity. - Add HTTP transport in addition to stdio. Keep each release small and deterministic. Run the full test suite before release. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 0486d5c..d926528 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -234,6 +234,21 @@ Inputs: The converter rejects mismatched dimensions and mismatched quantity categories. For example, it rejects a torque-to-energy conversion. +The registry covers length, mass, time, angle, temperature, force, pressure, torque, energy, power, velocity, acceleration, area, volume, second moment of area, density, linear mass, stiffness, frequency, dynamic viscosity, kinematic viscosity, and thermal conductivity. + +Viscosity examples: + +- `100 cP` to `Pa·s` gives `0.1`. +- `40 cSt` to `m2/s` gives `0.00004`. +- `1 P` to `Pa·s` gives `0.1`. + +Thermal conductivity examples: + +- `401 W/(m·K)` to `BTU/(ft·h·°F)` gives about `231.7`. +- `1 kcal/(m·h·°C)` to `W/(m·K)` gives about `1.162`. + +See [units.md](units.md) for the dimension model and the full category list. + ## interference_fit Compute the interface pressure, hoop stresses, and friction capacity of a press or shrink fit. diff --git a/docs/units.md b/docs/units.md new file mode 100644 index 0000000..0013582 --- /dev/null +++ b/docs/units.md @@ -0,0 +1,91 @@ +# Unit conversion layer + +The unit layer converts values between compatible units. +It rejects conversions that mix different quantities. +It is read-only and has no state. + +## The dimension model + +Every unit carries a dimension vector. +The vector has five axes: length, mass, time, temperature, and angle. +Each axis holds an integer exponent. +For example, pressure is length to minus one, mass to one, and time to minus two. + +A conversion is valid only when the two units have the same dimension vector. +It is valid only when the two units belong to the same quantity category. +The category check blocks a torque-to-energy conversion. +Torque and energy share the same dimension vector but are different quantities. + +## Unit categories + +The registry covers these categories: + +| Category | Example units | SI unit | +| --- | --- | --- | +| length | m, mm, in, ft | m | +| mass | kg, g, lb, t | kg | +| time | s, min, h, day | s | +| angle | rad, deg, rev | rad | +| temperature | K, degC, degF | K | +| force | N, kN, lbf, kgf | N | +| pressure | Pa, MPa, psi, bar | Pa | +| torque | N·m, kN·m, lbf·ft | N·m | +| energy | J, kJ, kWh, cal | J | +| power | W, kW, hp | W | +| velocity | m/s, km/h, mph | m/s | +| acceleration | m/s2, g0 | m/s2 | +| area | m2, mm2, cm2 | m2 | +| volume | m3, L, gal | m3 | +| second moment of area | m4, cm4, mm4 | m4 | +| density | kg/m3, g/cm3 | kg/m3 | +| linear mass | kg/m, g/m, lb/ft | kg/m | +| stiffness | N/m, N/mm, lbf/in | N/m | +| frequency | Hz, rpm, kHz | Hz | +| dynamic viscosity | Pa·s, cP, P | Pa·s | +| kinematic viscosity | m2/s, cSt, St | m2/s | +| thermal conductivity | W/(m·K), BTU/(ft·h·°F) | W/(m·K) | + +## Viscosity and thermal conductivity + +Dynamic viscosity measures a fluid's resistance to shear. +The SI unit is the pascal second. +One centipoise equals one millipascal second. +Water at room temperature is about one centipoise. + +Kinematic viscosity is the ratio of dynamic viscosity to density. +The SI unit is the square metre per second. +One centistoke equals one square millimetre per second. +Lubricating oils are usually quoted in centistokes. + +Thermal conductivity measures the rate of heat flow through a material. +The SI unit is the watt per metre kelvin. +Copper conducts at about 401 watts per metre kelvin. +Mild steel conducts at about 50 watts per metre kelvin. + +The temperature intervals cancel in these units. +A change of one degree Celsius equals a change of one kelvin. +A change of one degree Fahrenheit equals five ninths of a kelvin. + +## Conversion factors + +Each unit stores a factor to its SI unit. +The factor is exact for SI-derived units. +It is exact for definitions such as the inch and the pound. +It is a fixed published constant for old customary units. + +The registry stores the conversion factors directly. +It does not compute them from other units. +A contributor checks each factor against a published source. +Add a comment or a test that cites the source. + +## How to add a unit + +Add the unit to the array in `src/units/registry.ts`. +Give it a canonical symbol, a category, a dimension, and a factor. +Add aliases for common alternate spellings. +The alias list must normalize to distinct keys. + +Add deterministic tests in `tests/units.test.ts`. +Test a forward conversion and a rejection. +Test against a value you can verify by hand. +Run `npm test` to reproduce the checks. diff --git a/examples/demo.ts b/examples/demo.ts index 2216605..f80296a 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -104,6 +104,9 @@ async function main(): Promise { ["fatigue_analysis", toolHandlers.fatigue_analysis], ["unit_convert", toolHandlers.unit_convert], ["unit_convert (torque to energy)", toolHandlers.unit_convert], + ["unit_convert (dynamic viscosity)", toolHandlers.unit_convert], + ["unit_convert (kinematic viscosity)", toolHandlers.unit_convert], + ["unit_convert (thermal conductivity)", toolHandlers.unit_convert], ["material_lookup", toolHandlers.material_lookup], ["section_catalog", toolHandlers.section_catalog], ["beam_bending (IPE 300)", toolHandlers.beam_bending], @@ -181,6 +184,21 @@ async function main(): Promise { from: "N·m", to: "J", }, + { + value: 100, + from: "cP", + to: "Pa·s", + }, + { + value: 40, + from: "cSt", + to: "m2/s", + }, + { + value: 401, + from: "W/(m·K)", + to: "BTU/(ft·h·°F)", + }, { query: "steel", }, diff --git a/package-lock.json b/package-lock.json index b9fc9ea..cfb1c50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@engineerkit/engineer-mcp", - "version": "0.5.0", + "version": "0.6.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", diff --git a/package.json b/package.json index 5d320d3..64fd39c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.5.0", + "version": "0.6.0", "description": "MCP server for verified mechanical-engineering calculations, references, and material data.", "type": "module", "license": "MIT", diff --git a/src/units/dimensions.ts b/src/units/dimensions.ts index 64a1003..5c3bd10 100644 --- a/src/units/dimensions.ts +++ b/src/units/dimensions.ts @@ -21,6 +21,9 @@ export const DIM_DENSITY: Dimension = [-3, 1, 0, 0, 0]; export const DIM_LINEAR_DENSITY: Dimension = [-1, 1, 0, 0, 0]; export const DIM_STIFFNESS: Dimension = [0, 1, -2, 0, 0]; export const DIM_FREQUENCY: Dimension = [0, 0, -1, 0, 0]; +export const DIM_DYNAMIC_VISCOSITY: Dimension = [-1, 1, -1, 0, 0]; +export const DIM_KINEMATIC_VISCOSITY: Dimension = [2, 0, -1, 0, 0]; +export const DIM_THERMAL_CONDUCTIVITY: Dimension = [1, 1, -3, -1, 0]; export function dimensionsEqual(a: Dimension, b: Dimension): boolean { for (let i = 0; i < a.length; i += 1) { diff --git a/src/units/registry.ts b/src/units/registry.ts index 8d7a73d..61139fc 100644 --- a/src/units/registry.ts +++ b/src/units/registry.ts @@ -3,9 +3,11 @@ import { DIM_ANGLE, DIM_AREA, DIM_DENSITY, + DIM_DYNAMIC_VISCOSITY, DIM_ENERGY, DIM_FORCE, DIM_FREQUENCY, + DIM_KINEMATIC_VISCOSITY, DIM_LENGTH, DIM_LINEAR_DENSITY, DIM_MASS, @@ -14,6 +16,7 @@ import { DIM_SECOND_MOMENT, DIM_STIFFNESS, DIM_TEMPERATURE, + DIM_THERMAL_CONDUCTIVITY, DIM_TIME, DIM_TORQUE, DIM_VELOCITY, @@ -151,6 +154,25 @@ export const UNITS: UnitDef[] = [ unit({ canonical: "Hz", name: "hertz", category: "frequency", dim: DIM_FREQUENCY, factor: 1 }), unit({ canonical: "rpm", name: "revolution per minute", category: "frequency", dim: DIM_FREQUENCY, factor: 1 / 60 }), unit({ canonical: "kHz", name: "kilohertz", category: "frequency", dim: DIM_FREQUENCY, factor: 1e3 }), + + unit({ canonical: "Pa·s", aliases: ["Pa.s", "Pa s"], name: "pascal second", category: "dynamic viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 1 }), + unit({ canonical: "mPa·s", aliases: ["mPa.s", "mPa s"], name: "millipascal second", category: "dynamic viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 1e-3 }), + unit({ canonical: "P", aliases: ["poise"], name: "poise", category: "dynamic viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 0.1 }), + unit({ canonical: "cP", aliases: ["cp", "centipoise"], name: "centipoise", category: "dynamic viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 1e-3 }), + unit({ canonical: "kgf·s/m2", aliases: ["kgf.s/m2", "kgf s/m2"], name: "kilogram-force second per square metre", category: "dynamic viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 9.80665 }), + unit({ canonical: "lb/(ft·s)", aliases: ["lb/(ft.s)", "lb/ft/s"], name: "pound per foot second", category: "dynamic viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 1.4881639435696 }), + unit({ canonical: "lbf·s/ft2", aliases: ["lbf.s/ft2"], name: "pound-force second per square foot", category: "dynamic viscosity", dim: DIM_DYNAMIC_VISCOSITY, factor: 47.88025898 }), + + unit({ canonical: "m2/s", name: "square metre per second", category: "kinematic viscosity", dim: DIM_KINEMATIC_VISCOSITY, factor: 1 }), + unit({ canonical: "St", aliases: ["stokes"], name: "stokes", category: "kinematic viscosity", dim: DIM_KINEMATIC_VISCOSITY, factor: 1e-4 }), + unit({ canonical: "cSt", aliases: ["cst", "centistokes"], name: "centistokes", category: "kinematic viscosity", dim: DIM_KINEMATIC_VISCOSITY, factor: 1e-6 }), + unit({ canonical: "ft2/s", name: "square foot per second", category: "kinematic viscosity", dim: DIM_KINEMATIC_VISCOSITY, factor: 0.09290304 }), + + unit({ canonical: "W/(m·K)", aliases: ["W/(m.K)", "W/m/K"], name: "watt per metre kelvin", category: "thermal conductivity", dim: DIM_THERMAL_CONDUCTIVITY, factor: 1 }), + unit({ canonical: "W/(m·°C)", aliases: ["W/(m.C)", "W/m/C"], name: "watt per metre degree Celsius", category: "thermal conductivity", dim: DIM_THERMAL_CONDUCTIVITY, factor: 1 }), + unit({ canonical: "kcal/(m·h·°C)", aliases: ["kcal/(m.h.C)"], name: "kilocalorie per metre hour degree Celsius", category: "thermal conductivity", dim: DIM_THERMAL_CONDUCTIVITY, factor: 4184 / 3600 }), + unit({ canonical: "BTU/(ft·h·°F)", aliases: ["BTU/(ft.h.F)"], name: "British thermal unit per foot hour degree Fahrenheit", category: "thermal conductivity", dim: DIM_THERMAL_CONDUCTIVITY, factor: 1055.05585262 / 609.6 }), + unit({ canonical: "cal/(cm·s·°C)", aliases: ["cal/(cm.s.C)"], name: "calorie per centimetre second degree Celsius", category: "thermal conductivity", dim: DIM_THERMAL_CONDUCTIVITY, factor: 418.4 }), ]; export function normalizeSymbol(symbol: string): string { diff --git a/src/version.ts b/src/version.ts index fc2871b..ea97777 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const VERSION = "0.5.0"; +export const VERSION = "0.6.0"; export const SERVER_NAME = "engineer-mcp"; diff --git a/tests/units.test.ts b/tests/units.test.ts index 70402ee..3a22ce6 100644 --- a/tests/units.test.ts +++ b/tests/units.test.ts @@ -181,3 +181,119 @@ describe("linear mass units", () => { } }); }); + +describe("dynamic viscosity units", () => { + it("converts centipoise to pascal seconds", () => { + const outcome = convertUnit(100, "cP", "Pa·s"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(0.1, 9); + expect(outcome.category).toBe("dynamic viscosity"); + expect(outcome.siSymbol).toBe("Pa·s"); + } + }); + + it("converts poise to pascal seconds", () => { + const outcome = convertUnit(1, "P", "Pa·s"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(0.1, 9); + } + }); + + it("converts pound-force seconds per square foot to pascal seconds", () => { + const outcome = convertUnit(1, "lbf·s/ft2", "Pa·s"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(47.88025898, 6); + } + }); + + it("converts pounds per foot second to pascal seconds", () => { + const outcome = convertUnit(1, "lb/(ft·s)", "Pa·s"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(1.4881639435696, 6); + } + }); + + it("rejects a viscosity-to-pressure conversion", () => { + const outcome = convertUnit(1, "Pa·s", "Pa"); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.error).toContain("Dimension mismatch"); + } + }); +}); + +describe("kinematic viscosity units", () => { + it("converts centistokes to square metres per second", () => { + const outcome = convertUnit(40, "cSt", "m2/s"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(40e-6, 12); + expect(outcome.category).toBe("kinematic viscosity"); + expect(outcome.siSymbol).toBe("m2/s"); + } + }); + + it("converts stokes to centistokes", () => { + const outcome = convertUnit(1, "St", "cSt"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(100, 9); + } + }); + + it("converts square feet per second to square metres per second", () => { + const outcome = convertUnit(1, "ft2/s", "m2/s"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(0.09290304, 9); + } + }); + + it("rejects a kinematic-to-dynamic viscosity conversion", () => { + const outcome = convertUnit(40, "cSt", "cP"); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.error).toContain("Dimension mismatch"); + } + }); +}); + +describe("thermal conductivity units", () => { + it("converts watts per metre kelvin to watts per metre degree Celsius", () => { + const outcome = convertUnit(401, "W/(m·K)", "W/(m·°C)"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(401, 9); + expect(outcome.category).toBe("thermal conductivity"); + expect(outcome.siSymbol).toBe("W/(m·K)"); + } + }); + + it("converts watts per metre kelvin to British thermal units per foot hour degree Fahrenheit", () => { + const outcome = convertUnit(1, "W/(m·K)", "BTU/(ft·h·°F)"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(1 / 1.730734667, 6); + } + }); + + it("converts kilocalories per metre hour degree Celsius to watts per metre kelvin", () => { + const outcome = convertUnit(1, "kcal/(m·h·°C)", "W/(m·K)"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value).toBeCloseTo(4184 / 3600, 6); + } + }); + + it("rejects a conductivity-to-power conversion", () => { + const outcome = convertUnit(1, "W/(m·K)", "W"); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.error).toContain("Dimension mismatch"); + } + }); +}); From 382b7eecc4d1329756223a5ad5a17aa394b5d3f0 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:18:10 -0700 Subject: [PATCH 6/8] feat: extend engineer mcp --- .github/workflows/ci.yml | 6 + README.md | 67 +++++++++- docs/integration.md | 4 +- docs/transport.md | 118 ++++++++++++++++ examples/http-smoke.mjs | 126 +++++++++++++++++ examples/mcp-config.http.example.json | 7 + package-lock.json | 4 +- package.json | 3 +- src/http.ts | 116 ++++++++++++++++ src/index.ts | 67 ++++++++-- src/version.ts | 2 +- tests/http.test.ts | 186 ++++++++++++++++++++++++++ 12 files changed, 687 insertions(+), 19 deletions(-) create mode 100644 docs/transport.md create mode 100644 examples/http-smoke.mjs create mode 100644 examples/mcp-config.http.example.json create mode 100644 src/http.ts create mode 100644 tests/http.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11899a3..09844b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,9 +47,15 @@ jobs: node dist/index.js --list | grep -q "section_catalog" node dist/index.js --list | grep -q "interference_fit" node dist/index.js --list | grep -q "fatigue_analysis" + node dist/index.js --transport http --list | grep -q "beam_bending" + OUTPUT="$(node dist/index.js --transport unknown 2>&1 || true)" + echo "$OUTPUT" | grep -q "Unknown transport" - name: Verify the demo runs run: npm run demo + - name: Verify the HTTP transport + run: npm run smoke:http + - name: Verify the package contents run: npm pack --dry-run diff --git a/README.md b/README.md index 10f23ec..ac1a2bb 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ The release covers these domains: - Standard steel section catalog to EN 10365. - Dimension-safe unit conversion, including viscosity and thermal conductivity. - Material property lookup. +- Stdio and HTTP transports. + The HTTP mode serves the same tools over the Streamable HTTP protocol. ## How results stay trustworthy @@ -64,6 +66,7 @@ Warnings surface when a method uses an approximation. See [docs/mcp-tools.md](docs/mcp-tools.md) for the full reference. See [docs/section-catalog.md](docs/section-catalog.md) for the covered range, the value provenance, and the data audit. See [docs/units.md](docs/units.md) for the unit model and the full category list. +See [docs/transport.md](docs/transport.md) for the HTTP transport reference. ## Architecture @@ -74,7 +77,7 @@ The database seeds from JSON files on first start. ```mermaid flowchart LR - Agent[AI coding agent] -->|MCP over stdio| Server[MCP server] + Agent[AI coding agent] -->|MCP over stdio or HTTP| Server[MCP server] Server --> Tools[Tools layer] Tools --> Engines[Calculation engines] Tools --> Units[Unit layer] @@ -90,6 +93,8 @@ Key directories: | `src/units/` | Dimension-safe unit conversion. | | `src/db/` | SQLite schema and seeding. | | `src/handlers.ts` | Tool orchestration and result envelopes. | +| `src/http.ts` | Streamable HTTP transport and session registry. | +| `src/index.ts` | CLI entry point and transport selection. | | `data/` | Material, fastener, section, and reference data. | ## Quick start @@ -117,6 +122,22 @@ See [examples/mcp-config.example.json](examples/mcp-config.example.json) for a t Set `ENGINEER_MCP_DB` or pass `--db ` to choose the database file. The default database file is `engineer-mcp.sqlite` in the working directory. +## Run over HTTP + +Run the server with the HTTP transport. + +```sh +node dist/index.js --transport http +``` + +The server listens on `http://127.0.0.1:3000/mcp`. +Set `--host` and `--port` to change the bind address. +Set `ENGINEER_MCP_TRANSPORT`, `ENGINEER_MCP_HOST`, and `ENGINEER_MCP_PORT` to configure the same values. +See [docs/transport.md](docs/transport.md) for client configuration and curl examples. + +Use a port of `0` to let the operating system choose a free port. +The server prints the real port to standard error. + ## Sample output A call to `beam_bending` with a 20 kN point load on a 3 m S355 I-beam: @@ -240,6 +261,27 @@ References: - EN 10365 - Hot rolled steel channels, I and H sections - Dimensions and masses ``` +The same tools run over HTTP. +Start the server with `--transport http`, then start a session with curl: + +```sh +curl -s -D - http://127.0.0.1:3000/mcp \ + -H "content-type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' +``` + +The response carries the session id in the `Mcp-Session-Id` header. +Send that header on every later request: + +```sh +curl -s http://127.0.0.1:3000/mcp \ + -H "content-type: application/json" \ + -H "mcp-session-id: " \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' +``` + +See [docs/transport.md](docs/transport.md) for the full HTTP reference. + ## Development | Command | Purpose | @@ -248,17 +290,21 @@ References: | `npm test` | Run the deterministic test suite. | | `npm run build` | Emit `dist/` from `src/`. | | `npm run demo` | Run the end-to-end demo. | +| `npm run smoke:http` | Run the HTTP transport smoke check. | | `npm run dev` | Start the server from source. | ## Test status The test suite is deterministic and offline. -It covers the engines, the unit layer, the database, the tools, and the catalog data. +It covers the engines, the unit layer, the database, the tools, the catalog data, and the HTTP transport. -- 172 tests across 14 files. +- 180 tests across 15 files. - All tests pass on Node 22 and Node 24. -- The CI workflow runs typecheck, tests, build, demo, and a package check. +- The HTTP tests run a real server on an ephemeral port. + They complete the full handshake over a real TCP connection. +- The CI workflow runs typecheck, tests, build, demo, a package check, and the HTTP smoke check. - The CI workflow verifies the CLI contract over standard output. +- The CI workflow verifies both transport modes. Run `npm test` to reproduce the results. @@ -281,6 +327,11 @@ Run `npm test` to reproduce the results. It does not include every size in the standard. - The viscosity and thermal conductivity units cover common engineering units. They do not cover every named unit in older texts. +- The HTTP transport binds to the local host by default. + It has no authentication or encryption. + Use a reverse proxy for a public deployment. +- The HTTP transport keeps session state in memory. + A restart clears every active session. - The built-in SQLite module of Node.js is still experimental. Check the cited sources for exact values. @@ -304,10 +355,16 @@ Each release stays useful on its own. - Viscosity and thermal conductivity units. The `unit_convert` tool converts dynamic viscosity, kinematic viscosity, and thermal conductivity. The registry covers centipoise, centistokes, and the imperial conductivity units. +- HTTP transport. + The server runs over stdio or Streamable HTTP. + The `--transport http` option starts an HTTP endpoint with stateful sessions. ### Remaining -- Add HTTP transport. +- Add authentication and origin allow-lists to the HTTP transport. +- Make the HTTP response mode configurable. + The server returns JSON responses today. + An SSE-only client needs an explicit streaming mode. See [docs/integration.md](docs/integration.md) for the EngineerKit plan. diff --git a/docs/integration.md b/docs/integration.md index 2d58e9c..03ffa86 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -45,9 +45,11 @@ Engineer MCP grows in independent releases. Each release stays useful on its own - Press and shrink fit analysis. The `interference_fit` tool computes the interface pressure, the hoop stresses, and the friction capacity. - Fatigue analysis. The `fatigue_analysis` tool estimates the endurance limit for steel and computes the fatigue safety factor for a mean-stress criterion. - Viscosity and thermal conductivity units. The unit registry covers dynamic viscosity, kinematic viscosity, and thermal conductivity. The `unit_convert` tool converts between the common engineering units of each. +- HTTP transport. The server runs over stdio or Streamable HTTP. The `--transport http` option starts an HTTP endpoint with stateful sessions. See [transport.md](transport.md). ### Remaining -- Add HTTP transport in addition to stdio. +- Add authentication and origin allow-lists to the HTTP transport. +- Make the HTTP response mode configurable. The server returns JSON responses today. An SSE-only client needs an explicit streaming mode. Keep each release small and deterministic. Run the full test suite before release. diff --git a/docs/transport.md b/docs/transport.md new file mode 100644 index 0000000..8d1bd5f --- /dev/null +++ b/docs/transport.md @@ -0,0 +1,118 @@ +# HTTP transport + +Engineer MCP runs over standard input and output by default. +It also runs over HTTP with the Streamable HTTP transport. +Use HTTP when your MCP client does not support stdio. +A browser-based client or a remote client is a typical case. + +## Start the server + +Build the server first. + +```sh +npm run build +``` + +Then start the HTTP transport. + +```sh +node dist/index.js --transport http +``` + +The server listens on `http://127.0.0.1:3000/mcp`. + +Set these options to change the bind address. + +| Option | Default | Purpose | +| --- | --- | --- | +| `--host` | `127.0.0.1` | Bind address. | +| `--port` | `3000` | Listen port. | + +Use a port of `0` to let the operating system choose a free port. + +```sh +node dist/index.js --transport http --host 127.0.0.1 --port 0 +``` + +The server prints the real port to standard error. +Read it from the log line. + +You can set the same values with environment variables. + +| Environment variable | Purpose | +| --- | --- | +| `ENGINEER_MCP_TRANSPORT` | Transport mode: `stdio` or `http`. | +| `ENGINEER_MCP_HOST` | HTTP bind address. | +| `ENGINEER_MCP_PORT` | HTTP listen port. | +| `ENGINEER_MCP_DB` | SQLite database path. | + +The server exits on `SIGINT` or `SIGTERM`. +It closes all active sessions during shutdown. + +## Configure an MCP client + +Point the client at the endpoint URL. + +```json +{ + "mcpServers": { + "engineer-mcp": { + "url": "http://127.0.0.1:3000/mcp" + } + } +} +``` + +See `examples/mcp-config.http.example.json` for a complete example. + +## Sessions + +The HTTP transport uses stateful sessions. +The server creates a session during the initialize request. +It returns the session id in the `Mcp-Session-Id` header. +The client must send this header on every later request. + +The server keeps the session state in memory. +It removes a session on `DELETE` or on shutdown. + +## Check the server with curl + +Use the health endpoint to confirm the server is up. + +```sh +curl http://127.0.0.1:3000/health +``` + +The server returns a JSON status. + +```json +{ "ok": true, "name": "engineer-mcp", "version": "0.7.0" } +``` + +Start a session with an initialize request. + +```sh +curl -s -D - http://127.0.0.1:3000/mcp \ + -H "content-type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' +``` + +Read the session id from the `Mcp-Session-Id` response header. +List the tools with that session id. + +```sh +curl -s http://127.0.0.1:3000/mcp \ + -H "content-type: application/json" \ + -H "mcp-session-id: " \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' +``` + +## Limitations + +- The server binds to `127.0.0.1` by default. + It does not include authentication or encryption. + Use a reverse proxy for a public deployment. +- The server stores session state in memory. + A restart clears every active session. +- The transport does not validate origins or hosts. + Do not expose it to an untrusted network without protection. diff --git a/examples/http-smoke.mjs b/examples/http-smoke.mjs new file mode 100644 index 0000000..380fef3 --- /dev/null +++ b/examples/http-smoke.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +// HTTP transport smoke check. +// Build first (npm run build), then run: node examples/http-smoke.mjs +// The script starts the built server through its CLI, completes an MCP +// handshake over HTTP, and exits non-zero when any step fails. + +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const PROTOCOL_VERSION = "2025-11-25"; +const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SERVER_ENTRY = path.join(PROJECT_ROOT, "dist", "index.js"); + +async function waitForHealth(url, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(url); + if (res.status === 200) { + return true; + } + } catch { + // Server not up yet. + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return false; +} + +async function rpc(url, method, params, sessionId) { + const headers = { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-protocol-version": PROTOCOL_VERSION, + }; + if (sessionId) { + headers["mcp-session-id"] = sessionId; + } + const res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + const body = await res.json(); + return { status: res.status, sessionId: res.headers.get("mcp-session-id"), body }; +} + +function pickPort() { + return 20000 + Math.floor(Math.random() * 40000); +} + +async function run() { + const port = pickPort(); + const baseUrl = `http://127.0.0.1:${port}`; + const healthUrl = `${baseUrl}/health`; + const mcpUrl = `${baseUrl}/mcp`; + + const child = spawn(process.execPath, [SERVER_ENTRY, "--transport", "http", "--host", "127.0.0.1", "--port", String(port)], { + stdio: ["ignore", "pipe", "pipe"], + }); + + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + try { + const healthy = await waitForHealth(healthUrl, 10000); + if (!healthy) { + throw new Error(`Server did not become healthy. stderr:\n${stderr}`); + } + + const init = await rpc(mcpUrl, "initialize", { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "engineer-mcp-http-smoke", version: "1.0.0" }, + }); + if (init.status !== 200 || !init.sessionId) { + throw new Error(`Initialize failed with status ${init.status}.`); + } + + const list = await rpc(mcpUrl, "tools/list", {}, init.sessionId); + if (list.status !== 200) { + throw new Error(`tools/list failed with status ${list.status}.`); + } + const tools = list.body.result?.tools ?? []; + if (tools.length !== 12) { + throw new Error(`Expected 12 tools, received ${tools.length}.`); + } + if (!tools.some((tool) => tool.name === "beam_bending")) { + throw new Error("Expected beam_bending in the tool list."); + } + + const call = await rpc(mcpUrl, "tools/call", { + name: "beam_bending", + arguments: { + support: "simply_supported", + load: "point", + loadMagnitude: 20000, + length: 3, + material: "Structural steel S355", + section: { shape: "i_beam", height: 0.3, flangeWidth: 0.15, flangeThickness: 0.012, webThickness: 0.008 }, + }, + }, init.sessionId); + if (call.status !== 200 || call.body.result?.structuredContent?.tool !== "beam_bending") { + throw new Error("tools/call did not return the beam_bending result."); + } + + console.log(`HTTP smoke check passed on port ${port}.`); + console.log(" initialize: ok"); + console.log(` tools/list: ${tools.length} tools`); + console.log(" tools/call beam_bending: ok"); + } finally { + child.kill("SIGTERM"); + await new Promise((resolve) => { + child.once("exit", resolve); + setTimeout(resolve, 2000); + }); + } +} + +run().catch((error) => { + console.error(`HTTP smoke check failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/examples/mcp-config.http.example.json b/examples/mcp-config.http.example.json new file mode 100644 index 0000000..b6a6b5a --- /dev/null +++ b/examples/mcp-config.http.example.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "engineer-mcp": { + "url": "http://127.0.0.1:3000/mcp" + } + } +} diff --git a/package-lock.json b/package-lock.json index cfb1c50..75152ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@engineerkit/engineer-mcp", - "version": "0.6.0", + "version": "0.7.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", diff --git a/package.json b/package.json index 64fd39c..f1d673b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.6.0", + "version": "0.7.0", "description": "MCP server for verified mechanical-engineering calculations, references, and material data.", "type": "module", "license": "MIT", @@ -62,6 +62,7 @@ "start": "node dist/index.js", "dev": "tsx src/index.ts", "demo": "tsx examples/demo.ts", + "smoke:http": "node examples/http-smoke.mjs", "prepublishOnly": "npm run typecheck && npm test && npm run build" }, "dependencies": { diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..d4052cc --- /dev/null +++ b/src/http.ts @@ -0,0 +1,116 @@ +import { randomUUID } from "node:crypto"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { AppContext } from "./context.js"; +import { buildServer } from "./server.js"; +import { SERVER_NAME, VERSION } from "./version.js"; + +export type HttpServerOptions = { + host: string; + port: number; +}; + +export type HttpServerHandle = { + host: string; + port: number; + close(): Promise; +}; + +type Session = { + server: McpServer; + transport: StreamableHTTPServerTransport; +}; + +const SESSION_HEADER = "mcp-session-id"; +const HEALTH_PATH = "/health"; + +function readSessionId(req: IncomingMessage): string | undefined { + const value = req.headers[SESSION_HEADER]; + if (Array.isArray(value)) { + return value[0]; + } + return value; +} + +function writeJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +export function createHttpServer(ctx: AppContext, options: HttpServerOptions): Promise { + const sessions = new Map(); + + const httpServer = createServer((req, res) => { + void handleRequest(ctx, sessions, req, res).catch(() => { + writeJson(res, 500, { error: "Internal server error" }); + }); + }); + + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + httpServer.removeListener("listening", onListening); + reject(error); + }; + const onListening = () => { + httpServer.removeListener("error", onError); + const address = httpServer.address(); + const port = typeof address === "object" && address ? address.port : options.port; + resolve({ + host: options.host, + port, + close() { + return new Promise((resolveClose) => { + httpServer.close(() => resolveClose()); + httpServer.closeAllConnections(); + }); + }, + }); + }; + httpServer.once("error", onError); + httpServer.once("listening", onListening); + httpServer.listen(options.port, options.host); + }); +} + +async function handleRequest( + ctx: AppContext, + sessions: Map, + req: IncomingMessage, + res: ServerResponse, +): Promise { + if (req.method === "GET" && req.url && new URL(req.url, "http://127.0.0.1").pathname === HEALTH_PATH) { + writeJson(res, 200, { ok: true, name: SERVER_NAME, version: VERSION }); + return; + } + + const sessionId = readSessionId(req); + let session: Session | undefined; + if (sessionId) { + session = sessions.get(sessionId); + if (!session) { + writeJson(res, 404, { error: "Session not found" }); + return; + } + } + if (!session) { + session = await createSession(ctx, sessions); + } + await session.transport.handleRequest(req, res); +} + +async function createSession(ctx: AppContext, sessions: Map): Promise { + const server = buildServer(ctx); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true, + onsessioninitialized(id) { + sessions.set(id, { server, transport }); + }, + onsessionclosed(id) { + sessions.delete(id); + }, + }); + await server.connect(transport); + return { server, transport }; +} diff --git a/src/index.ts b/src/index.ts index ae98dc8..c2e91ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import "./warnings.js"; import { parseArgs } from "node:util"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createHttpServer } from "./http.js"; import { buildServer, listTools } from "./server.js"; import { SERVER_NAME, VERSION } from "./version.js"; @@ -13,12 +14,18 @@ Usage: engineer-mcp [options] Options: - --db SQLite database path. Defaults to ENGINEER_MCP_DB or engineer-mcp.sqlite. - --list List available tools and exit. - -v, --version Print the version and exit. - -h, --help Show this help and exit. + --db SQLite database path. Defaults to ENGINEER_MCP_DB or engineer-mcp.sqlite. + --transport Transport mode: stdio (default) or http. + --host HTTP bind host. Defaults to 127.0.0.1. + --port HTTP listen port. Defaults to 3000. Use 0 for a free port. + --list List available tools and exit. + -v, --version Print the version and exit. + -h, --help Show this help and exit. `; +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_PORT = 3000; + function info(message: string): void { process.stderr.write(`${message}\n`); } @@ -27,10 +34,44 @@ function out(message: string): void { process.stdout.write(`${message}\n`); } +function parsePort(value: string | undefined): number { + if (!value) { + return DEFAULT_PORT; + } + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) { + throw new Error(`Invalid port: ${value}. Use an integer between 0 and 65535.`); + } + return parsed; +} + +async function runStdio(dbPath: string): Promise { + const { createContext } = await import("./context.js"); + const ctx = createContext(dbPath); + const server = buildServer(ctx); + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +async function runHttp(dbPath: string, host: string, port: number): Promise { + const { createContext } = await import("./context.js"); + const ctx = createContext(dbPath); + const handle = await createHttpServer(ctx, { host, port }); + info(`${SERVER_NAME} v${VERSION} listening on http://${handle.host}:${handle.port}/mcp`); + const shutdown = () => { + void handle.close().then(() => process.exit(0)); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + async function main(): Promise { const { values } = parseArgs({ options: { db: { type: "string" }, + transport: { type: "string" }, + host: { type: "string" }, + port: { type: "string" }, list: { type: "boolean" }, version: { type: "boolean", short: "v" }, help: { type: "boolean", short: "h" }, @@ -54,12 +95,20 @@ async function main(): Promise { } const dbPath = values.db ?? process.env.ENGINEER_MCP_DB ?? "engineer-mcp.sqlite"; + const transportMode = values.transport ?? process.env.ENGINEER_MCP_TRANSPORT ?? "stdio"; try { - const { createContext } = await import("./context.js"); - const ctx = createContext(dbPath); - const server = buildServer(ctx); - const transport = new StdioServerTransport(); - await server.connect(transport); + if (transportMode === "http") { + const host = values.host ?? process.env.ENGINEER_MCP_HOST ?? DEFAULT_HOST; + const port = parsePort(values.port ?? process.env.ENGINEER_MCP_PORT); + await runHttp(dbPath, host, port); + return; + } + if (transportMode === "stdio") { + await runStdio(dbPath); + return; + } + info(`Unknown transport: ${transportMode}. Use stdio or http.`); + process.exit(1); } catch (error: unknown) { info(`Failed to start server: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); diff --git a/src/version.ts b/src/version.ts index ea97777..4142930 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const VERSION = "0.6.0"; +export const VERSION = "0.7.0"; export const SERVER_NAME = "engineer-mcp"; diff --git a/tests/http.test.ts b/tests/http.test.ts new file mode 100644 index 0000000..ba427c0 --- /dev/null +++ b/tests/http.test.ts @@ -0,0 +1,186 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createContext, type AppContext } from "../src/context.js"; +import { createHttpServer, type HttpServerHandle } from "../src/http.js"; +import { VERSION } from "../src/version.js"; + +const PROTOCOL_VERSION = "2025-11-25"; + +type RpcResponse = { + status: number; + sessionId?: string; + body: { + jsonrpc?: string; + id?: number; + result?: Record | { [key: string]: unknown }; + error?: { code?: number; message?: string }; + }; +}; + +let ctx: AppContext; +let handle: HttpServerHandle | undefined; + +async function startServer(): Promise { + ctx = createContext(":memory:"); + handle = await createHttpServer(ctx, { host: "127.0.0.1", port: 0 }); + return handle; +} + +afterEach(async () => { + if (handle) { + await handle.close(); + handle = undefined; + } +}); + +async function rpc( + url: string, + method: string, + params: unknown, + sessionId?: string, +): Promise { + const headers: Record = { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-protocol-version": PROTOCOL_VERSION, + }; + if (sessionId) { + headers["mcp-session-id"] = sessionId; + } + const res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + const body = (await res.json()) as RpcResponse["body"]; + return { + status: res.status, + sessionId: res.headers.get("mcp-session-id") ?? undefined, + body, + }; +} + +async function initialize(url: string): Promise { + return rpc(url, "initialize", { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "engineer-mcp-tests", version: "1.0.0" }, + }); +} + +describe("HTTP transport", () => { + it("serves a health endpoint", async () => { + const server = await startServer(); + const res = await fetch(`http://127.0.0.1:${server.port}/health`); + const body = (await res.json()) as { ok?: boolean; name?: string; version?: string }; + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(body.name).toBe("engineer-mcp"); + expect(body.version).toBe(VERSION); + }); + + it("completes the initialize handshake and returns a session id", async () => { + const server = await startServer(); + const response = await initialize(`http://127.0.0.1:${server.port}/mcp`); + + expect(response.status).toBe(200); + expect(response.sessionId).toBeDefined(); + expect(response.body.result?.serverInfo).toMatchObject({ name: "engineer-mcp", version: VERSION }); + }); + + it("lists the registered tools on a session", async () => { + const server = await startServer(); + const url = `http://127.0.0.1:${server.port}/mcp`; + const init = await initialize(url); + expect(init.sessionId).toBeDefined(); + + const tools = await rpc(url, "tools/list", {}, init.sessionId); + expect(tools.status).toBe(200); + const result = tools.body.result as { tools?: Array<{ name: string }> }; + expect(result.tools?.map((tool) => tool.name)).toContain("beam_bending"); + expect(result.tools?.map((tool) => tool.name)).toContain("section_catalog"); + expect(result.tools?.length).toBe(12); + }); + + it("calls a tool and returns the result envelope", async () => { + const server = await startServer(); + const url = `http://127.0.0.1:${server.port}/mcp`; + const init = await initialize(url); + expect(init.sessionId).toBeDefined(); + + const call = await rpc( + url, + "tools/call", + { + name: "beam_bending", + arguments: { + support: "simply_supported", + load: "point", + loadMagnitude: 20000, + length: 3, + material: "Structural steel S355", + section: { shape: "i_beam", height: 0.3, flangeWidth: 0.15, flangeThickness: 0.012, webThickness: 0.008 }, + }, + }, + init.sessionId, + ); + expect(call.status).toBe(200); + const result = call.body.result as { + structuredContent?: { tool?: string; quantities?: Array<{ key: string }> }; + }; + expect(result.structuredContent?.tool).toBe("beam_bending"); + const keys = result.structuredContent?.quantities?.map((q) => q.key) ?? []; + expect(keys).toContain("maxBendingStress"); + expect(keys).toContain("maxBendingMoment"); + }); + + it("reports a tool failure as an error result", async () => { + const server = await startServer(); + const url = `http://127.0.0.1:${server.port}/mcp`; + const init = await initialize(url); + expect(init.sessionId).toBeDefined(); + + const call = await rpc( + url, + "tools/call", + { name: "unit_convert", arguments: { value: 10, from: "N·m", to: "J" } }, + init.sessionId, + ); + const result = call.body.result as { + isError?: boolean; + content?: Array<{ type?: string; text?: string }>; + }; + expect(result.isError).toBe(true); + expect(result.content?.[0]?.text).toContain("Category mismatch"); + }); + + it("rejects a non-initialization request without a session id", async () => { + const server = await startServer(); + const response = await rpc(`http://127.0.0.1:${server.port}/mcp`, "tools/list", {}); + expect(response.status).toBe(400); + }); + + it("rejects an unknown session id", async () => { + const server = await startServer(); + const response = await rpc(`http://127.0.0.1:${server.port}/mcp`, "tools/list", {}, "does-not-exist"); + expect(response.status).toBe(404); + }); + + it("removes a session on DELETE", async () => { + const server = await startServer(); + const url = `http://127.0.0.1:${server.port}/mcp`; + const init = await initialize(url); + expect(init.sessionId).toBeDefined(); + + const del = await fetch(url, { + method: "DELETE", + headers: { + "mcp-session-id": init.sessionId ?? "", + "mcp-protocol-version": PROTOCOL_VERSION, + }, + }); + expect(del.status).toBe(200); + + const after = await rpc(url, "tools/list", {}, init.sessionId); + expect(after.status).toBe(404); + }); +}); From a194d037e285246ed7d1323d762a51db7f139304 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:14:55 -0700 Subject: [PATCH 7/8] feat: extend engineer mcp --- README.md | 7 ++++- data/references.json | 13 +++++++-- docs/integration.md | 5 +++- docs/section-catalog.md | 32 +++++++++++++--------- src/context.ts | 4 +++ src/db/database.ts | 43 ++++++++++++++++++++++------- src/handlers.ts | 13 +++++---- tests/section-data-audit.test.ts | 9 +++++- tests/standard-sections.test.ts | 47 +++++++++++++++++++++++++++++++- 9 files changed, 138 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index ac1a2bb..2fcc563 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ The release covers these domains: - Cross-section properties. - Press and shrink fit analysis by Lamé theory. - Standard steel section catalog to EN 10365. + The catalog returns the separate source for published section properties. - Dimension-safe unit conversion, including viscosity and thermal conductivity. - Material property lookup. - Stdio and HTTP transports. @@ -245,6 +246,7 @@ Rows: Method: Standard section catalog lookup References: - EN 10365 - Hot rolled steel channels, I and H sections - Dimensions and masses + - European sections - dimensions and section properties ``` Pass a catalog designation to `beam_bending` to use the published section properties: @@ -259,6 +261,7 @@ References: - Roark's Formulas for Stress and Strain (Eighth edition, 2011) - Mechanics of Materials (Euler-Bernoulli beam theory) - EN 10365 - Hot rolled steel channels, I and H sections - Dimensions and masses + - European sections - dimensions and section properties ``` The same tools run over HTTP. @@ -298,7 +301,7 @@ See [docs/transport.md](docs/transport.md) for the full HTTP reference. The test suite is deterministic and offline. It covers the engines, the unit layer, the database, the tools, the catalog data, and the HTTP transport. -- 180 tests across 15 files. +- 182 tests across 15 files. - All tests pass on Node 22 and Node 24. - The HTTP tests run a real server on an ephemeral port. They complete the full handshake over a real TCP connection. @@ -348,6 +351,8 @@ Each release stays useful on its own. - Standard steel section catalog. The `section_catalog` tool searches the published IPE, HEA, HEB, and UPN series. The `beam_bending` and `section_properties` tools accept a catalog designation. + The result cites EN 10365 for dimensions and masses. + It cites ArcelorMittal for section properties. - Press and shrink fit analysis. The `interference_fit` tool reports the interface pressure, the hoop stresses, and the friction capacity. - Fatigue analysis. diff --git a/data/references.json b/data/references.json index 224c937..05a992a 100644 --- a/data/references.json +++ b/data/references.json @@ -59,8 +59,17 @@ "title": "EN 10365 - Hot rolled steel channels, I and H sections - Dimensions and masses", "source": "European Committee for Standardization", "edition": "EN 10365:2017", - "section": "Dimensions, masses, and section properties of rolled sections", - "note": "Nominal dimensions and section properties for IPE, HEA, HEB, and UPN series." + "section": "Scope and nominal dimensions and masses for IPE, HE, and UPN profiles", + "url": "https://www.evs.ee/et/evs-en-10365-2017", + "note": "This standard specifies nominal dimensions and masses. It does not supply the section-property columns used by this catalog." + }, + "arcelormittal-sections": { + "title": "European sections - dimensions and section properties", + "source": "ArcelorMittal Europe", + "edition": "Section catalogue", + "section": "IPE tables pp. 72-77; HE tables pp. 80-87; UPN tables pp. 98-100", + "url": "https://sections.arcelormittal.com/repository2/Sections/5_1_5_ArcelorMittal_FR_EN_RU_web.pdf", + "note": "The catalog supplies the area, strong-axis inertia, and elastic section modulus used for the supported profiles." }, "lame-cylinders": { "title": "Theory of Elasticity", diff --git a/docs/integration.md b/docs/integration.md index 03ffa86..b289200 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -41,7 +41,10 @@ Engineer MCP grows in independent releases. Each release stays useful on its own ### Complete - Helical compression spring design. The `spring_design` tool computes the spring rate, the shear stress, and the safety factor. -- Standard section catalog. The `section_catalog` tool searches published IPE, HEA, HEB, and UPN sections. The `beam_bending` and `section_properties` tools accept a standard designation. +- Standard section catalog. + The `section_catalog` tool searches published IPE, HEA, HEB, and UPN sections. + The result cites EN 10365 for dimensions and masses. + It cites ArcelorMittal for section properties. - Press and shrink fit analysis. The `interference_fit` tool computes the interface pressure, the hoop stresses, and the friction capacity. - Fatigue analysis. The `fatigue_analysis` tool estimates the endurance limit for steel and computes the fatigue safety factor for a mean-stress criterion. - Viscosity and thermal conductivity units. The unit registry covers dynamic viscosity, kinematic viscosity, and thermal conductivity. The `unit_convert` tool converts between the common engineering units of each. diff --git a/docs/section-catalog.md b/docs/section-catalog.md index 9c0fa1e..1ab223c 100644 --- a/docs/section-catalog.md +++ b/docs/section-catalog.md @@ -24,27 +24,33 @@ The other series step through the even numbers in their range. ## Source of the values -The values come from the published tables of EN 10365. -See the entry `en-10365` in `data/references.json` for the full citation. -Every row stores the same reference id. -The `section_catalog` tool returns this reference with each result. +The catalog uses two sources. +The result includes both source records. +See `data/references.json` for the full citations and source URLs. + +- [EN 10365:2017](https://www.evs.ee/et/evs-en-10365-2017) defines nominal dimensions and masses. +- [ArcelorMittal European section tables](https://sections.arcelormittal.com/repository2/Sections/5_1_5_ArcelorMittal_FR_EN_RU_web.pdf) provide the properties. Each property column has a defined source: | Column | Meaning | Source | | --- | --- | --- | -| `heightMm` | Overall section height | Nominal dimension in the standard tables | -| `flangeWidthMm` | Overall flange width | Nominal dimension in the standard tables | -| `webThicknessMm` | Web thickness | Nominal dimension in the standard tables | -| `flangeThicknessMm` | Flange thickness | Nominal dimension in the standard tables | -| `areaCm2` | Cross-section area | Published value in the standard tables | -| `massPerMetreKgM` | Mass per unit length | Published value in the standard tables | -| `secondMomentCm4` | Second moment of area about the strong axis | Published value in the standard tables | -| `sectionModulusCm3` | Elastic section modulus about the strong axis | Published value in the standard tables | +| `heightMm` | Overall section height | EN 10365:2017, nominal dimensions | +| `flangeWidthMm` | Overall flange width | EN 10365:2017, nominal dimensions | +| `webThicknessMm` | Web thickness | EN 10365:2017, nominal dimensions | +| `flangeThicknessMm` | Flange thickness | EN 10365:2017, nominal dimensions | +| `areaCm2` | Cross-section area | ArcelorMittal European section tables | +| `massPerMetreKgM` | Mass per unit length | EN 10365:2017, nominal masses | +| `secondMomentCm4` | Second moment of area about the strong axis | ArcelorMittal European section tables | +| `sectionModulusCm3` | Elastic section modulus about the strong axis | ArcelorMittal European section tables | Fillets and root radii are included in the published values. Do not compute the section properties from the nominal plate dimensions alone. +The EN 10365 entry cites the standard scope. +The ArcelorMittal entry cites the tables that supply area, inertia, and elastic modulus. +Do not treat the standard as the source for those properties. + ## Audit Each value was checked against published tables for the standard series. @@ -61,7 +67,7 @@ The check allows a tolerance of 1 percent. The small deviation comes from the rounded values in the tables. The test file `tests/section-data-audit.test.ts` runs these rules. -It also checks the covered range and the uniqueness of the designations. +It also checks the covered range, the source split, and the uniqueness of the designations. Run `npm test` to reproduce the audit. ## Data files diff --git a/src/context.ts b/src/context.ts index 14812e8..274a47d 100644 --- a/src/context.ts +++ b/src/context.ts @@ -34,6 +34,8 @@ export type StandardSectionRow = { designation: string; series: string; standard: string; + dimensionsReferenceId: string; + propertiesReferenceId: string; heightMm: number; flangeWidthMm: number; webThicknessMm: number; @@ -172,6 +174,8 @@ function mapSection(row: Record): StandardSectionRow { designation: row.designation as string, series: row.series as string, standard: row.standard as string, + dimensionsReferenceId: row.reference_id as string, + propertiesReferenceId: row.properties_reference_id as string, heightMm: row.height_mm as number, flangeWidthMm: row.flange_width_mm as number, webThicknessMm: row.web_thickness_mm as number, diff --git a/src/db/database.ts b/src/db/database.ts index ca41e38..986e92f 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -53,7 +53,8 @@ CREATE TABLE IF NOT EXISTS standard_sections ( mass_per_metre_kg_m REAL NOT NULL, second_moment_cm4 REAL NOT NULL, section_modulus_cm3 REAL NOT NULL, - reference_id TEXT + reference_id TEXT, + properties_reference_id TEXT ); `; @@ -61,23 +62,44 @@ export function createDatabase(path: string): DatabaseSync { const db = new DatabaseSync(path); db.exec("PRAGMA journal_mode = WAL;"); db.exec(SCHEMA); + ensureSectionProvenanceColumns(db); seedIfEmpty(db); + backfillSectionPropertiesReference(db); return db; } +function ensureSectionProvenanceColumns(db: DatabaseSync): void { + const columns = db.prepare("PRAGMA table_info(standard_sections)").all() as Array<{ name: string }>; + if (!columns.some((column) => column.name === "properties_reference_id")) { + db.exec("ALTER TABLE standard_sections ADD COLUMN properties_reference_id TEXT"); + } +} + +function backfillSectionPropertiesReference(db: DatabaseSync): void { + db.prepare( + "UPDATE standard_sections SET properties_reference_id = ? WHERE properties_reference_id IS NULL", + ).run("arcelormittal-sections"); +} + function tableIsEmpty(db: DatabaseSync, table: string): boolean { const row = db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }; return row.count === 0; } export function seedIfEmpty(db: DatabaseSync): void { - if (tableIsEmpty(db, "sources")) { - const insert = db.prepare( - "INSERT INTO sources (id, title, source, edition, section, url, note) VALUES (?, ?, ?, ?, ?, ?, ?)", - ); - for (const [id, record] of Object.entries(loadReferences())) { - insert.run(id, record.title, record.source, record.edition ?? null, record.section ?? null, record.url ?? null, record.note ?? null); - } + const insertReference = db.prepare( + `INSERT INTO sources (id, title, source, edition, section, url, note) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + source = excluded.source, + edition = excluded.edition, + section = excluded.section, + url = excluded.url, + note = excluded.note`, + ); + for (const [id, record] of Object.entries(loadReferences())) { + insertReference.run(id, record.title, record.source, record.edition ?? null, record.section ?? null, record.url ?? null, record.note ?? null); } if (tableIsEmpty(db, "materials")) { @@ -112,8 +134,8 @@ export function seedIfEmpty(db: DatabaseSync): void { if (tableIsEmpty(db, "standard_sections")) { const insert = db.prepare( `INSERT INTO standard_sections - (designation, series, standard, height_mm, flange_width_mm, web_thickness_mm, flange_thickness_mm, area_cm2, mass_per_metre_kg_m, second_moment_cm4, section_modulus_cm3, reference_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (designation, series, standard, height_mm, flange_width_mm, web_thickness_mm, flange_thickness_mm, area_cm2, mass_per_metre_kg_m, second_moment_cm4, section_modulus_cm3, reference_id, properties_reference_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); for (const s of loadSections()) { insert.run( @@ -129,6 +151,7 @@ export function seedIfEmpty(db: DatabaseSync): void { s.secondMomentCm4, s.sectionModulusCm3, "en-10365", + "arcelormittal-sections", ); } } diff --git a/src/handlers.ts b/src/handlers.ts index aa916cb..10899f8 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -38,8 +38,8 @@ const SECTION_CATALOG_METHOD: MethodRecord = { name: "Standard section catalog lookup", formula: "Database query of published rolled-section dimensions and section properties", notes: - "Dimensions, masses, and section properties follow the nominal values in the cited standard. Fillets and root radii are included.", - referenceIds: ["en-10365"], + "EN 10365 supplies nominal dimensions and masses. ArcelorMittal supplies the section-property columns. Fillets and root radii remain included.", + referenceIds: ["en-10365", "arcelormittal-sections"], }; const UNIT_METHOD: MethodRecord = { @@ -126,9 +126,9 @@ type SectionInput = SectionDef | { shape: "standard"; designation: string }; function resolveStandardSection( ctx: AppContext, section: SectionInput | undefined, -): { secondMomentOfArea: number; sectionModulus: number } | { error: string } { +): { secondMomentOfArea: number; sectionModulus: number; referenceIds: string[] } | { error: string } { if (!section || section.shape !== "standard") { - return { secondMomentOfArea: 0, sectionModulus: 0 }; + return { secondMomentOfArea: 0, sectionModulus: 0, referenceIds: [] }; } const row = ctx.findSection(section.designation); if (!row) { @@ -138,6 +138,7 @@ function resolveStandardSection( return { secondMomentOfArea: row.secondMomentCm4 * 1e-8, sectionModulus: row.sectionModulusCm3 * 1e-6, + referenceIds: [row.dimensionsReferenceId, row.propertiesReferenceId], }; } @@ -188,7 +189,7 @@ function beamHandler(ctx: AppContext): Handler { sectionModulus: usesStandard ? resolved.sectionModulus : (input.sectionModulus as number | undefined), }); if (usesStandard) { - computation.referenceIds = [...computation.referenceIds, "en-10365"]; + computation.referenceIds = [...computation.referenceIds, ...resolved.referenceIds]; } return buildResult(ctx, "beam_bending", computation, input.outputUnits as Record | undefined); } catch (error) { @@ -262,7 +263,7 @@ function sectionPropsHandler(ctx: AppContext): Handler { description: "Radius of gyration about the strong axis, derived from the published values.", }, ], - referenceIds: ["en-10365"], + referenceIds: [row.dimensionsReferenceId, row.propertiesReferenceId], warnings: [], }; return buildResult(ctx, "section_properties", computation, input.outputUnits as Record | undefined); diff --git a/tests/section-data-audit.test.ts b/tests/section-data-audit.test.ts index cbd3ec7..20d971f 100644 --- a/tests/section-data-audit.test.ts +++ b/tests/section-data-audit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { loadSections, type SectionSeed } from "../src/assets.js"; +import { loadReferences, loadSections, type SectionSeed } from "../src/assets.js"; const STEEL_DENSITY_KG_M3 = 7850; const MASS_TOLERANCE = 0.005; @@ -25,6 +25,13 @@ function sectionsBySeries(sections: SectionSeed[]): Map { } describe("section catalog data audit", () => { + it("keeps dimensions and section properties linked to separate sources", () => { + const references = loadReferences(); + expect(references["en-10365"]?.note).toContain("does not supply the section-property columns"); + expect(references["arcelormittal-sections"]?.section).toContain("IPE tables"); + expect(references["arcelormittal-sections"]?.url).toContain("ArcelorMittal_FR_EN_RU_web.pdf"); + }); + it("keeps every designation unique", () => { const designations = loadSections().map((section) => section.designation); expect(new Set(designations).size).toBe(designations.length); diff --git a/tests/standard-sections.test.ts b/tests/standard-sections.test.ts index 3f11f29..b8d240c 100644 --- a/tests/standard-sections.test.ts +++ b/tests/standard-sections.test.ts @@ -1,3 +1,7 @@ +import { DatabaseSync } from "node:sqlite"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createContext, type AppContext, type StandardSectionRow } from "../src/context.js"; import { createHandlers, type Handler } from "../src/handlers.js"; @@ -23,6 +27,43 @@ function expectOk(response: Awaited>): ToolResult { } describe("standard section catalog", () => { + it("backfills provenance for a legacy SQLite catalog", () => { + const directory = mkdtempSync(join(tmpdir(), "engineer-mcp-sections-")); + const path = join(directory, "catalog.sqlite"); + const legacy = new DatabaseSync(path); + legacy.exec(` + CREATE TABLE standard_sections ( + designation TEXT PRIMARY KEY, + series TEXT NOT NULL, + standard TEXT NOT NULL, + height_mm REAL NOT NULL, + flange_width_mm REAL NOT NULL, + web_thickness_mm REAL NOT NULL, + flange_thickness_mm REAL NOT NULL, + area_cm2 REAL NOT NULL, + mass_per_metre_kg_m REAL NOT NULL, + second_moment_cm4 REAL NOT NULL, + section_modulus_cm3 REAL NOT NULL, + reference_id TEXT + ); + `); + legacy + .prepare("INSERT INTO standard_sections VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + .run("IPE 300", "IPE", "EN 10365", 300, 150, 7.1, 10.7, 53.8, 42.2, 8356, 557, "en-10365"); + legacy.close(); + + const ctx = createContext(path); + try { + const section = ctx.findSection("IPE 300"); + expect(section?.dimensionsReferenceId).toBe("en-10365"); + expect(section?.propertiesReferenceId).toBe("arcelormittal-sections"); + expect(ctx.references.has("arcelormittal-sections")).toBe(true); + } finally { + ctx.db.close(); + rmSync(directory, { recursive: true, force: true }); + } + }); + it("loads the catalog and finds a section by exact designation", () => { setup(); const section = ctx.findSection("IPE 300"); @@ -31,6 +72,8 @@ describe("standard section catalog", () => { expect(section?.heightMm).toBe(300); expect(section?.secondMomentCm4).toBe(8356); expect(section?.sectionModulusCm3).toBe(557); + expect(section?.dimensionsReferenceId).toBe("en-10365"); + expect(section?.propertiesReferenceId).toBe("arcelormittal-sections"); }); it("returns undefined for an unknown designation", () => { @@ -72,7 +115,7 @@ describe("section_catalog tool", () => { const result = expectOk(response); expect(result.tool).toBe("section_catalog"); expect(result.method.id).toBe("section-catalog"); - expect(result.references[0]?.id).toBe("en-10365"); + expect(result.references.map((reference) => reference.id)).toEqual(["en-10365", "arcelormittal-sections"]); expect(result.rows?.[0]).toMatchObject({ designation: "HEB 200", series: "HEB", @@ -117,6 +160,7 @@ describe("section_properties tool with a standard section", () => { expect(mass?.unit).toBe("kg/m"); expect(result.references.some((ref) => ref.id === "en-10365")).toBe(true); + expect(result.references.some((ref) => ref.id === "arcelormittal-sections")).toBe(true); }); it("converts quantities on request", () => { @@ -168,6 +212,7 @@ describe("beam_bending tool with a standard section", () => { expect(result.quantities.find((q) => q.key === "maxBendingStress")?.value).toBeCloseTo(expectedStressPa / 1e6, 6); expect(result.quantities.find((q) => q.key === "maxDeflection")?.value).toBeCloseTo(expectedDeflection * 1000, 9); expect(result.references.some((ref) => ref.id === "en-10365")).toBe(true); + expect(result.references.some((ref) => ref.id === "arcelormittal-sections")).toBe(true); }); it("reports an unknown standard designation", () => { From f283b9434bb7891100d681fb6b88810e1306af80 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:25:44 -0700 Subject: [PATCH 8/8] feat: extend engineer mcp --- README.md | 23 ++++++++--- docs/integration.md | 2 +- docs/transport.md | 27 ++++++++++--- examples/http-smoke.mjs | 40 +++++++++++++++++- package-lock.json | 4 +- package.json | 2 +- src/http-security.ts | 89 +++++++++++++++++++++++++++++++++++++++++ src/http.ts | 35 +++++++++++++++- src/index.ts | 19 +++++++-- src/version.ts | 2 +- tests/http.test.ts | 83 ++++++++++++++++++++++++++++++++++++-- 11 files changed, 301 insertions(+), 25 deletions(-) create mode 100644 src/http-security.ts diff --git a/README.md b/README.md index 2fcc563..e3179cf 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ The release covers these domains: - Press and shrink fit analysis by Lamé theory. - Standard steel section catalog to EN 10365. The catalog returns the separate source for published section properties. +- Bearer authentication and browser origin allow-lists for HTTP clients. - Dimension-safe unit conversion, including viscosity and thermal conductivity. - Material property lookup. - Stdio and HTTP transports. @@ -78,7 +79,9 @@ The database seeds from JSON files on first start. ```mermaid flowchart LR - Agent[AI coding agent] -->|MCP over stdio or HTTP| Server[MCP server] + Agent[AI coding agent] -->|stdio| Server[MCP server] + Agent -->|HTTP| Security[HTTP security policy] + Security --> Server Server --> Tools[Tools layer] Tools --> Engines[Calculation engines] Tools --> Units[Unit layer] @@ -95,6 +98,7 @@ Key directories: | `src/db/` | SQLite schema and seeding. | | `src/handlers.ts` | Tool orchestration and result envelopes. | | `src/http.ts` | Streamable HTTP transport and session registry. | +| `src/http-security.ts` | Bearer authentication and browser-origin policy. | | `src/index.ts` | CLI entry point and transport selection. | | `data/` | Material, fastener, section, and reference data. | @@ -110,6 +114,9 @@ The demo prints results for every tool. It runs against an in-memory database. It needs no API keys and no network access. +Configure HTTP authentication with `ENGINEER_MCP_AUTH_TOKEN`. +Configure browser access with `ENGINEER_MCP_ALLOWED_ORIGINS`. + ## Run as an MCP server Run the server over standard input and output. @@ -301,7 +308,7 @@ See [docs/transport.md](docs/transport.md) for the full HTTP reference. The test suite is deterministic and offline. It covers the engines, the unit layer, the database, the tools, the catalog data, and the HTTP transport. -- 182 tests across 15 files. +- 185 tests across 15 files. - All tests pass on Node 22 and Node 24. - The HTTP tests run a real server on an ephemeral port. They complete the full handshake over a real TCP connection. @@ -331,8 +338,12 @@ Run `npm test` to reproduce the results. - The viscosity and thermal conductivity units cover common engineering units. They do not cover every named unit in older texts. - The HTTP transport binds to the local host by default. - It has no authentication or encryption. - Use a reverse proxy for a public deployment. + Authentication is optional. + Set `ENGINEER_MCP_AUTH_TOKEN` before a protected deployment. +- Browser clients need an explicit origin allow-list. + Set `ENGINEER_MCP_ALLOWED_ORIGINS` with comma-separated origins. + The transport does not provide TLS. + Use a reverse proxy for public deployment. - The HTTP transport keeps session state in memory. A restart clears every active session. - The built-in SQLite module of Node.js is still experimental. @@ -363,10 +374,12 @@ Each release stays useful on its own. - HTTP transport. The server runs over stdio or Streamable HTTP. The `--transport http` option starts an HTTP endpoint with stateful sessions. +- HTTP transport security. + The server supports bearer authentication. + It rejects browser origins outside the configured allow-list. ### Remaining -- Add authentication and origin allow-lists to the HTTP transport. - Make the HTTP response mode configurable. The server returns JSON responses today. An SSE-only client needs an explicit streaming mode. diff --git a/docs/integration.md b/docs/integration.md index b289200..db1954d 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -49,10 +49,10 @@ Engineer MCP grows in independent releases. Each release stays useful on its own - Fatigue analysis. The `fatigue_analysis` tool estimates the endurance limit for steel and computes the fatigue safety factor for a mean-stress criterion. - Viscosity and thermal conductivity units. The unit registry covers dynamic viscosity, kinematic viscosity, and thermal conductivity. The `unit_convert` tool converts between the common engineering units of each. - HTTP transport. The server runs over stdio or Streamable HTTP. The `--transport http` option starts an HTTP endpoint with stateful sessions. See [transport.md](transport.md). +- HTTP transport security. The server supports bearer authentication and browser origin allow-lists. ### Remaining -- Add authentication and origin allow-lists to the HTTP transport. - Make the HTTP response mode configurable. The server returns JSON responses today. An SSE-only client needs an explicit streaming mode. Keep each release small and deterministic. Run the full test suite before release. diff --git a/docs/transport.md b/docs/transport.md index 8d1bd5f..9954e7b 100644 --- a/docs/transport.md +++ b/docs/transport.md @@ -27,6 +27,7 @@ Set these options to change the bind address. | --- | --- | --- | | `--host` | `127.0.0.1` | Bind address. | | `--port` | `3000` | Listen port. | +| `--allowed-origin ` | None | Allow one browser origin. | Use a port of `0` to let the operating system choose a free port. @@ -45,10 +46,28 @@ You can set the same values with environment variables. | `ENGINEER_MCP_HOST` | HTTP bind address. | | `ENGINEER_MCP_PORT` | HTTP listen port. | | `ENGINEER_MCP_DB` | SQLite database path. | +| `ENGINEER_MCP_AUTH_TOKEN` | Bearer token for HTTP requests. | +| `ENGINEER_MCP_ALLOWED_ORIGINS` | Comma-separated browser origins. | The server exits on `SIGINT` or `SIGTERM`. It closes all active sessions during shutdown. +## Security + +Set `ENGINEER_MCP_AUTH_TOKEN` to require a bearer token. +Send `Authorization: Bearer ` with every non-preflight request. + +Set `ENGINEER_MCP_ALLOWED_ORIGINS` to allow browser origins. +Separate multiple origins with commas. + +You can repeat `--allowed-origin ` instead. +The server accepts requests without an Origin header. +It rejects browser requests outside the allow-list. +Approved browser requests receive CORS headers. +The response exposes `Mcp-Session-Id` for browser clients. + +The health endpoint uses the same authentication and origin rules. + ## Configure an MCP client Point the client at the endpoint URL. @@ -86,7 +105,7 @@ curl http://127.0.0.1:3000/health The server returns a JSON status. ```json -{ "ok": true, "name": "engineer-mcp", "version": "0.7.0" } +{ "ok": true, "name": "engineer-mcp", "version": "0.8.0" } ``` Start a session with an initialize request. @@ -110,9 +129,7 @@ curl -s http://127.0.0.1:3000/mcp \ ## Limitations - The server binds to `127.0.0.1` by default. - It does not include authentication or encryption. - Use a reverse proxy for a public deployment. + The transport does not provide TLS. + Use a reverse proxy for public deployment. - The server stores session state in memory. A restart clears every active session. -- The transport does not validate origins or hosts. - Do not expose it to an untrusted network without protection. diff --git a/examples/http-smoke.mjs b/examples/http-smoke.mjs index 380fef3..98e2ffb 100644 --- a/examples/http-smoke.mjs +++ b/examples/http-smoke.mjs @@ -9,6 +9,8 @@ import { fileURLToPath } from "node:url"; import path from "node:path"; const PROTOCOL_VERSION = "2025-11-25"; +const AUTH_TOKEN = "engineer-mcp-smoke-token"; +const ALLOWED_ORIGIN = "https://smoke.example"; const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const SERVER_ENTRY = path.join(PROJECT_ROOT, "dist", "index.js"); @@ -16,7 +18,12 @@ async function waitForHealth(url, timeoutMs) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { - const res = await fetch(url); + const res = await fetch(url, { + headers: { + authorization: `Bearer ${AUTH_TOKEN}`, + origin: ALLOWED_ORIGIN, + }, + }); if (res.status === 200) { return true; } @@ -33,6 +40,8 @@ async function rpc(url, method, params, sessionId) { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": PROTOCOL_VERSION, + authorization: `Bearer ${AUTH_TOKEN}`, + origin: ALLOWED_ORIGIN, }; if (sessionId) { headers["mcp-session-id"] = sessionId; @@ -58,6 +67,11 @@ async function run() { const child = spawn(process.execPath, [SERVER_ENTRY, "--transport", "http", "--host", "127.0.0.1", "--port", String(port)], { stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + ENGINEER_MCP_AUTH_TOKEN: AUTH_TOKEN, + ENGINEER_MCP_ALLOWED_ORIGINS: ALLOWED_ORIGIN, + }, }); let stderr = ""; @@ -80,6 +94,29 @@ async function run() { throw new Error(`Initialize failed with status ${init.status}.`); } + const blocked = await fetch(mcpUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "mcp-protocol-version": PROTOCOL_VERSION, + authorization: `Bearer ${AUTH_TOKEN}`, + origin: "https://untrusted.example", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "initialize", + params: { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "engineer-mcp-http-smoke", version: "1.0.0" }, + }, + }), + }); + if (blocked.status !== 403) { + throw new Error(`Origin check failed with status ${blocked.status}.`); + } + const list = await rpc(mcpUrl, "tools/list", {}, init.sessionId); if (list.status !== 200) { throw new Error(`tools/list failed with status ${list.status}.`); @@ -109,6 +146,7 @@ async function run() { console.log(`HTTP smoke check passed on port ${port}.`); console.log(" initialize: ok"); + console.log(" origin allow-list: ok"); console.log(` tools/list: ${tools.length} tools`); console.log(" tools/call beam_bending: ok"); } finally { diff --git a/package-lock.json b/package-lock.json index 75152ec..7d3fd4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@engineerkit/engineer-mcp", - "version": "0.7.0", + "version": "0.8.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", diff --git a/package.json b/package.json index f1d673b..0867001 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.7.0", + "version": "0.8.0", "description": "MCP server for verified mechanical-engineering calculations, references, and material data.", "type": "module", "license": "MIT", diff --git a/src/http-security.ts b/src/http-security.ts new file mode 100644 index 0000000..b39f0a6 --- /dev/null +++ b/src/http-security.ts @@ -0,0 +1,89 @@ +import { timingSafeEqual } from "node:crypto"; + +export type HttpSecurityOptions = { + authToken?: string; + allowedOrigins?: readonly string[]; +}; + +export type RequestHeaders = { + authorization?: string; + method?: string; + origin?: string; +}; + +export type SecurityDecision = + | { ok: true; origin?: string } + | { ok: false; status: 401 | 403; error: "Unauthorized" | "Origin not allowed" }; + +export type HttpSecurityPolicy = { + check(headers: RequestHeaders): SecurityDecision; + corsHeaders(origin: string | undefined): Record; +}; + +const CORS_HEADERS: Record = { + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", + "Access-Control-Allow-Headers": + "Accept, Authorization, Content-Type, Mcp-Protocol-Version, Mcp-Session-Id", + "Access-Control-Expose-Headers": "Mcp-Session-Id", + Vary: "Origin", +}; + +export function normalizeOrigin(value: string): string { + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol)) { + throw new Error(`Unsupported origin protocol: ${url.protocol}`); + } + if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) { + throw new Error("Allowed origins must contain only a scheme, host, and optional port."); + } + return url.origin; +} + +export function createHttpSecurity(options: HttpSecurityOptions = {}): HttpSecurityPolicy { + if (options.authToken !== undefined && options.authToken.length === 0) { + throw new Error("The HTTP authentication token must not be empty."); + } + + const allowedOrigins = new Set((options.allowedOrigins ?? []).map(normalizeOrigin)); + + return { + check(headers) { + const origin = headers.origin === undefined ? undefined : parseRequestOrigin(headers.origin); + if (headers.origin !== undefined && (!origin || !allowedOrigins.has(origin))) { + return { ok: false, status: 403, error: "Origin not allowed" }; + } + if ( + headers.method !== "OPTIONS" && + options.authToken !== undefined && + !hasBearerToken(headers.authorization, options.authToken) + ) { + return { ok: false, status: 401, error: "Unauthorized" }; + } + return origin ? { ok: true, origin } : { ok: true }; + }, + corsHeaders(origin) { + if (!origin || !allowedOrigins.has(origin)) { + return {}; + } + return { ...CORS_HEADERS, "Access-Control-Allow-Origin": origin }; + }, + }; +} + +function parseRequestOrigin(value: string): string | undefined { + try { + return normalizeOrigin(value.trim()); + } catch { + return undefined; + } +} + +function hasBearerToken(authorization: string | undefined, expected: string): boolean { + const prefix = "Bearer "; + if (!authorization?.startsWith(prefix)) { + return false; + } + const received = Buffer.from(authorization.slice(prefix.length)); + const expectedBytes = Buffer.from(expected); + return received.length === expectedBytes.length && timingSafeEqual(received, expectedBytes); +} diff --git a/src/http.ts b/src/http.ts index d4052cc..71a2497 100644 --- a/src/http.ts +++ b/src/http.ts @@ -3,10 +3,11 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import type { AppContext } from "./context.js"; +import { createHttpSecurity, type HttpSecurityOptions } from "./http-security.js"; import { buildServer } from "./server.js"; import { SERVER_NAME, VERSION } from "./version.js"; -export type HttpServerOptions = { +export type HttpServerOptions = HttpSecurityOptions & { host: string; port: number; }; @@ -40,9 +41,10 @@ function writeJson(res: ServerResponse, status: number, body: unknown): void { export function createHttpServer(ctx: AppContext, options: HttpServerOptions): Promise { const sessions = new Map(); + const security = createHttpSecurity(options); const httpServer = createServer((req, res) => { - void handleRequest(ctx, sessions, req, res).catch(() => { + void handleRequest(ctx, sessions, security, req, res).catch(() => { writeJson(res, 500, { error: "Internal server error" }); }); }); @@ -76,9 +78,33 @@ export function createHttpServer(ctx: AppContext, options: HttpServerOptions): P async function handleRequest( ctx: AppContext, sessions: Map, + security: ReturnType, req: IncomingMessage, res: ServerResponse, ): Promise { + const decision = security.check({ + authorization: readHeader(req, "authorization"), + method: req.method, + origin: readHeader(req, "origin"), + }); + const corsHeaders = security.corsHeaders(decision.ok ? decision.origin : undefined); + for (const [name, value] of Object.entries(corsHeaders)) { + res.setHeader(name, value); + } + if (!decision.ok) { + if (decision.status === 401) { + res.setHeader("WWW-Authenticate", "Bearer"); + } + writeJson(res, decision.status, { error: decision.error }); + return; + } + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + if (req.method === "GET" && req.url && new URL(req.url, "http://127.0.0.1").pathname === HEALTH_PATH) { writeJson(res, 200, { ok: true, name: SERVER_NAME, version: VERSION }); return; @@ -99,6 +125,11 @@ async function handleRequest( await session.transport.handleRequest(req, res); } +function readHeader(req: IncomingMessage, name: string): string | undefined { + const value = req.headers[name]; + return Array.isArray(value) ? value[0] : value; +} + async function createSession(ctx: AppContext, sessions: Map): Promise { const server = buildServer(ctx); const transport = new StreamableHTTPServerTransport({ diff --git a/src/index.ts b/src/index.ts index c2e91ff..1ffb8e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ Options: --transport Transport mode: stdio (default) or http. --host HTTP bind host. Defaults to 127.0.0.1. --port HTTP listen port. Defaults to 3000. Use 0 for a free port. + --allowed-origin Allow browser origin. Repeat for multiple origins. --list List available tools and exit. -v, --version Print the version and exit. -h, --help Show this help and exit. @@ -45,6 +46,10 @@ function parsePort(value: string | undefined): number { return parsed; } +function parseAllowedOrigins(value: string | undefined): string[] { + return value ? value.split(",").map((origin) => origin.trim()).filter(Boolean) : []; +} + async function runStdio(dbPath: string): Promise { const { createContext } = await import("./context.js"); const ctx = createContext(dbPath); @@ -53,10 +58,16 @@ async function runStdio(dbPath: string): Promise { await server.connect(transport); } -async function runHttp(dbPath: string, host: string, port: number): Promise { +async function runHttp( + dbPath: string, + host: string, + port: number, + authToken: string | undefined, + allowedOrigins: readonly string[], +): Promise { const { createContext } = await import("./context.js"); const ctx = createContext(dbPath); - const handle = await createHttpServer(ctx, { host, port }); + const handle = await createHttpServer(ctx, { host, port, authToken, allowedOrigins }); info(`${SERVER_NAME} v${VERSION} listening on http://${handle.host}:${handle.port}/mcp`); const shutdown = () => { void handle.close().then(() => process.exit(0)); @@ -72,6 +83,7 @@ async function main(): Promise { transport: { type: "string" }, host: { type: "string" }, port: { type: "string" }, + "allowed-origin": { type: "string", multiple: true }, list: { type: "boolean" }, version: { type: "boolean", short: "v" }, help: { type: "boolean", short: "h" }, @@ -100,7 +112,8 @@ async function main(): Promise { if (transportMode === "http") { const host = values.host ?? process.env.ENGINEER_MCP_HOST ?? DEFAULT_HOST; const port = parsePort(values.port ?? process.env.ENGINEER_MCP_PORT); - await runHttp(dbPath, host, port); + const allowedOrigins = values["allowed-origin"] ?? parseAllowedOrigins(process.env.ENGINEER_MCP_ALLOWED_ORIGINS); + await runHttp(dbPath, host, port, process.env.ENGINEER_MCP_AUTH_TOKEN, allowedOrigins); return; } if (transportMode === "stdio") { diff --git a/src/version.ts b/src/version.ts index 4142930..e849d7f 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const VERSION = "0.7.0"; +export const VERSION = "0.8.0"; export const SERVER_NAME = "engineer-mcp"; diff --git a/tests/http.test.ts b/tests/http.test.ts index ba427c0..5c1a24a 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { createContext, type AppContext } from "../src/context.js"; -import { createHttpServer, type HttpServerHandle } from "../src/http.js"; +import { createHttpServer, type HttpServerHandle, type HttpServerOptions } from "../src/http.js"; import { VERSION } from "../src/version.js"; const PROTOCOL_VERSION = "2025-11-25"; @@ -8,20 +8,26 @@ const PROTOCOL_VERSION = "2025-11-25"; type RpcResponse = { status: number; sessionId?: string; + allowOrigin?: string; body: { jsonrpc?: string; id?: number; result?: Record | { [key: string]: unknown }; - error?: { code?: number; message?: string }; + error?: { code?: number; message?: string } | string; }; }; +type RequestOptions = { + authorization?: string; + origin?: string; +}; + let ctx: AppContext; let handle: HttpServerHandle | undefined; -async function startServer(): Promise { +async function startServer(options: Partial = {}): Promise { ctx = createContext(":memory:"); - handle = await createHttpServer(ctx, { host: "127.0.0.1", port: 0 }); + handle = await createHttpServer(ctx, { host: "127.0.0.1", port: 0, ...options }); return handle; } @@ -37,6 +43,7 @@ async function rpc( method: string, params: unknown, sessionId?: string, + options: RequestOptions = {}, ): Promise { const headers: Record = { "content-type": "application/json", @@ -46,6 +53,12 @@ async function rpc( if (sessionId) { headers["mcp-session-id"] = sessionId; } + if (options.authorization) { + headers.authorization = options.authorization; + } + if (options.origin) { + headers.origin = options.origin; + } const res = await fetch(url, { method: "POST", headers, @@ -55,6 +68,7 @@ async function rpc( return { status: res.status, sessionId: res.headers.get("mcp-session-id") ?? undefined, + allowOrigin: res.headers.get("access-control-allow-origin") ?? undefined, body, }; } @@ -67,6 +81,20 @@ async function initialize(url: string): Promise { }); } +async function initializeWithOptions(url: string, options: RequestOptions): Promise { + return rpc( + url, + "initialize", + { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "engineer-mcp-tests", version: "1.0.0" }, + }, + undefined, + options, + ); +} + describe("HTTP transport", () => { it("serves a health endpoint", async () => { const server = await startServer(); @@ -78,6 +106,53 @@ describe("HTTP transport", () => { expect(body.version).toBe(VERSION); }); + it("requires a bearer token when authentication is configured", async () => { + const server = await startServer({ authToken: "test-token" }); + const url = `http://127.0.0.1:${server.port}/health`; + + const rejected = await fetch(url); + expect(rejected.status).toBe(401); + expect(rejected.headers.get("www-authenticate")).toBe("Bearer"); + + const accepted = await fetch(url, { headers: { authorization: "Bearer test-token" } }); + expect(accepted.status).toBe(200); + }); + + it("rejects unlisted browser origins", async () => { + const server = await startServer({ allowedOrigins: ["https://client.example"] }); + const url = `http://127.0.0.1:${server.port}/mcp`; + + const response = await initializeWithOptions(url, { origin: "https://untrusted.example" }); + expect(response.status).toBe(403); + expect(response.body.error).toBe("Origin not allowed"); + }); + + it("answers an approved CORS preflight and exposes the session header", async () => { + const server = await startServer({ authToken: "test-token", allowedOrigins: ["https://client.example"] }); + const url = `http://127.0.0.1:${server.port}/mcp`; + const response = await fetch(url, { + method: "OPTIONS", + headers: { + origin: "https://client.example", + "access-control-request-method": "POST", + "access-control-request-headers": "authorization, content-type, mcp-session-id", + }, + }); + + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe("https://client.example"); + expect(response.headers.get("access-control-expose-headers")).toBe("Mcp-Session-Id"); + expect(response.headers.get("access-control-allow-methods")).toContain("POST"); + + const initialized = await initializeWithOptions(url, { + authorization: "Bearer test-token", + origin: "https://client.example", + }); + expect(initialized.status).toBe(200); + expect(initialized.sessionId).toBeDefined(); + expect(initialized.allowOrigin).toBe("https://client.example"); + }); + it("completes the initialize handshake and returns a session id", async () => { const server = await startServer(); const response = await initialize(`http://127.0.0.1:${server.port}/mcp`);