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 7087f8b..09844b2 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 }} @@ -36,14 +40,22 @@ jobs: - name: Build run: npm run build - - name: Verify the demo runs - run: npm run demo - - - name: Verify the CLI list pipes to standard output + - name: Verify the CLI contract run: | - node dist/index.js --version + 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" + 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/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 88b62ff..e3179cf 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. @@ -23,10 +22,16 @@ 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. -- Dimension-safe unit conversion. +- 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. + The HTTP mode serves the same tools over the Streamable HTTP protocol. ## How results stay trustworthy @@ -55,10 +60,15 @@ 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. | 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 @@ -69,7 +79,9 @@ 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] -->|stdio| Server[MCP server] + Agent -->|HTTP| Security[HTTP security policy] + Security --> Server Server --> Tools[Tools layer] Tools --> Engines[Calculation engines] Tools --> Units[Unit layer] @@ -85,7 +97,10 @@ 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. | +| `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. | ## Quick start @@ -99,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. @@ -112,6 +130,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: @@ -169,6 +203,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 @@ -176,6 +223,75 @@ 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 +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 + - European sections - dimensions and section properties +``` + +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 + - European sections - dimensions and section properties +``` + +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 | @@ -184,17 +300,21 @@ Use a unit of the same quantity. | `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, and the tools. +It covers the engines, the unit layer, the database, the tools, the catalog data, and the HTTP transport. -- 109 tests across 11 files. +- 185 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 CI workflow verifies that the CLI tool list pipes to standard output. +- 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. @@ -207,8 +327,25 @@ 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. + 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. + 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. Check the cited sources for exact values. @@ -222,15 +359,30 @@ 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. + 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. + 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. +- 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 fatigue analysis for cyclic loads. -- Add more unit categories, including viscosity and thermal conductivity. -- Add HTTP transport. -- Add a catalog of ISO and DIN standard sections. +- 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/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/data/references.json b/data/references.json index 117d3b9..05a992a 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": { @@ -55,6 +55,22 @@ "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": "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", "source": "Timoshenko and Goodier, Lamé solution for thick-walled cylinders", 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 82e0d73..db1954d 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 @@ -41,13 +41,18 @@ 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 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. +- 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 fatigue analysis for cyclic loads. -- 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. +- 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/mcp-tools.md b/docs/mcp-tools.md index 6c4657c..d926528 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,40 @@ 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`. + +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 +{ + "query": "HEB", + "limit": 5 +} +``` ## bolt_strength @@ -146,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. @@ -158,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/section-catalog.md b/docs/section-catalog.md new file mode 100644 index 0000000..1ab223c --- /dev/null +++ b/docs/section-catalog.md @@ -0,0 +1,79 @@ +# 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 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 | 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. +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, the source split, 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/docs/transport.md b/docs/transport.md new file mode 100644 index 0000000..9954e7b --- /dev/null +++ b/docs/transport.md @@ -0,0 +1,135 @@ +# 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. | +| `--allowed-origin ` | None | Allow one browser origin. | + +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. | +| `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. + +```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.8.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. + 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. 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 cab30c0..f80296a 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -33,7 +33,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`); + } } } @@ -70,13 +76,15 @@ type ToolHandlers = { beam_bending: Handler; section_properties: Handler; bolt_strength: Handler; + interference_fit: Handler; spring_design: Handler; shaft_analysis: Handler; bearing_life: Handler; von_mises: Handler; + fatigue_analysis: Handler; unit_convert: Handler; material_lookup: Handler; - interference_fit: Handler; + section_catalog: Handler; }; async function main(): Promise { @@ -93,9 +101,15 @@ 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], + ["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], ["interference_fit", toolHandlers.interference_fit], ]; @@ -151,6 +165,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", @@ -161,9 +184,37 @@ 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", }, + { + 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, diff --git a/examples/http-smoke.mjs b/examples/http-smoke.mjs new file mode 100644 index 0000000..98e2ffb --- /dev/null +++ b/examples/http-smoke.mjs @@ -0,0 +1,164 @@ +#!/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 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"); + +async function waitForHealth(url, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(url, { + headers: { + authorization: `Bearer ${AUTH_TOKEN}`, + origin: ALLOWED_ORIGIN, + }, + }); + 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, + authorization: `Bearer ${AUTH_TOKEN}`, + origin: ALLOWED_ORIGIN, + }; + 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"], + env: { + ...process.env, + ENGINEER_MCP_AUTH_TOKEN: AUTH_TOKEN, + ENGINEER_MCP_ALLOWED_ORIGINS: ALLOWED_ORIGIN, + }, + }); + + 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 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}.`); + } + 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(" origin allow-list: 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 edcef02..7d3fd4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.3.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@engineerkit/engineer-mcp", - "version": "0.3.0", + "version": "0.8.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", diff --git a/package.json b/package.json index 2a8612f..0867001 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@engineerkit/engineer-mcp", - "version": "0.3.0", + "version": "0.8.0", "description": "MCP server for verified mechanical-engineering calculations, references, and material data.", "type": "module", "license": "MIT", @@ -50,6 +50,8 @@ "unit-conversion", "sqlite", "provenance", + "steel-sections", + "press-fit", "engineerkit" ], "scripts": { @@ -60,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/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..274a47d 100644 --- a/src/context.ts +++ b/src/context.ts @@ -30,6 +30,22 @@ export type GradeRow = { ultimateStressMPa: number; }; +export type StandardSectionRow = { + designation: string; + series: string; + standard: string; + dimensionsReferenceId: string; + propertiesReferenceId: 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 +55,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 +112,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 +168,21 @@ 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, + 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, + 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..986e92f 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,29 +40,66 @@ 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, + properties_reference_id TEXT +); `; 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")) { @@ -93,4 +130,29 @@ 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, properties_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", + "arcelormittal-sections", + ); + } + } } 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 38d1479..c4c2845 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -1,7 +1,18 @@ 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 { analyzePressFit, PRESS_FIT_METHOD, type FitInput } from "./fit.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"; export { diff --git a/src/handlers.ts b/src/handlers.ts index d0323f9..10899f8 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"; @@ -30,6 +33,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: + "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 = { id: "unit-convert", name: "Dimension-safe unit conversion", @@ -109,6 +121,27 @@ type MaterialValues = { yieldStrengthPa?: number; }; +type SectionInput = SectionDef | { shape: "standard"; designation: string }; + +function resolveStandardSection( + ctx: AppContext, + section: SectionInput | undefined, +): { secondMomentOfArea: number; sectionModulus: number; referenceIds: string[] } | { error: string } { + if (!section || section.shape !== "standard") { + return { secondMomentOfArea: 0, sectionModulus: 0, referenceIds: [] }; + } + 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, + referenceIds: [row.dimensionsReferenceId, row.propertiesReferenceId], + }; +} + function materialValues(ctx: AppContext, name: string): MaterialValues | undefined { const material = ctx.findMaterial(name); if (!material) { @@ -136,6 +169,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", @@ -144,10 +184,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, ...resolved.referenceIds]; + } 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); @@ -157,8 +200,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: [row.dimensionsReferenceId, row.propertiesReferenceId], + 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", @@ -294,6 +406,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 { @@ -463,6 +600,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), @@ -473,7 +643,9 @@ 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/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 new file mode 100644 index 0000000..71a2497 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,147 @@ +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 { createHttpSecurity, type HttpSecurityOptions } from "./http-security.js"; +import { buildServer } from "./server.js"; +import { SERVER_NAME, VERSION } from "./version.js"; + +export type HttpServerOptions = HttpSecurityOptions & { + 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 security = createHttpSecurity(options); + + const httpServer = createServer((req, res) => { + void handleRequest(ctx, sessions, security, 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, + 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; + } + + 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); +} + +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({ + 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..1ffb8e5 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,19 @@ 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. + --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. `; +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_PORT = 3000; + function info(message: string): void { process.stderr.write(`${message}\n`); } @@ -27,10 +35,55 @@ 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; +} + +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); + const server = buildServer(ctx); + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +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, authToken, allowedOrigins }); + 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" }, + "allowed-origin": { type: "string", multiple: true }, list: { type: "boolean" }, version: { type: "boolean", short: "v" }, help: { type: "boolean", short: "h" }, @@ -54,12 +107,21 @@ 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); + 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") { + 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/schemas.ts b/src/schemas.ts index 9312212..c2f6290 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( @@ -120,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."), @@ -148,6 +195,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; @@ -155,5 +207,8 @@ 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; +export type SectionCatalogInput = z.infer; diff --git a/src/server.ts b/src/server.ts index fd2f928..119669e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,8 +6,10 @@ import { beamSchema, bearingSchema, boltSchema, + fatigueSchema, fitSchema, materialSchema, + sectionCatalogSchema, sectionPropsSchema, shaftSchema, springSchema, @@ -58,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, @@ -66,6 +73,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..5c3bd10 100644 --- a/src/units/dimensions.ts +++ b/src/units/dimensions.ts @@ -16,9 +16,14 @@ 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]; +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 64eeff9..61139fc 100644 --- a/src/units/registry.ts +++ b/src/units/registry.ts @@ -3,15 +3,20 @@ 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, DIM_POWER, DIM_PRESSURE, + DIM_SECOND_MOMENT, DIM_STIFFNESS, DIM_TEMPERATURE, + DIM_THERMAL_CONDUCTIVITY, DIM_TIME, DIM_TORQUE, DIM_VELOCITY, @@ -128,10 +133,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 }), @@ -140,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 03714b1..e849d7f 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const VERSION = "0.3.0"; +export const VERSION = "0.8.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/fit.test.ts b/tests/fit.test.ts index b9325ac..e154ed6 100644 --- a/tests/fit.test.ts +++ b/tests/fit.test.ts @@ -70,6 +70,16 @@ describe("press fit engine", () => { expect(hollowStress).toBeGreaterThan(solidPressure); }); + 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("warns when the torque capacity falls below the required torque", () => { const result = analyzePressFit({ ...BASE, diff --git a/tests/http.test.ts b/tests/http.test.ts new file mode 100644 index 0000000..5c1a24a --- /dev/null +++ b/tests/http.test.ts @@ -0,0 +1,261 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createContext, type AppContext } from "../src/context.js"; +import { createHttpServer, type HttpServerHandle, type HttpServerOptions } from "../src/http.js"; +import { VERSION } from "../src/version.js"; + +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 } | string; + }; +}; + +type RequestOptions = { + authorization?: string; + origin?: string; +}; + +let ctx: AppContext; +let handle: HttpServerHandle | undefined; + +async function startServer(options: Partial = {}): Promise { + ctx = createContext(":memory:"); + handle = await createHttpServer(ctx, { host: "127.0.0.1", port: 0, ...options }); + return handle; +} + +afterEach(async () => { + if (handle) { + await handle.close(); + handle = undefined; + } +}); + +async function rpc( + url: string, + method: string, + params: unknown, + sessionId?: string, + options: RequestOptions = {}, +): 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; + } + if (options.authorization) { + headers.authorization = options.authorization; + } + if (options.origin) { + headers.origin = options.origin; + } + 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, + allowOrigin: res.headers.get("access-control-allow-origin") ?? 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" }, + }); +} + +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(); + 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("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`); + + 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); + }); +}); diff --git a/tests/section-data-audit.test.ts b/tests/section-data-audit.test.ts new file mode 100644 index 0000000..20d971f --- /dev/null +++ b/tests/section-data-audit.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { loadReferences, 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 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); + }); + + 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); + } + } + }); +}); diff --git a/tests/standard-sections.test.ts b/tests/standard-sections.test.ts new file mode 100644 index 0000000..b8d240c --- /dev/null +++ b/tests/standard-sections.test.ts @@ -0,0 +1,233 @@ +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"; +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("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"); + expect(section).toBeDefined(); + expect(section?.series).toBe("IPE"); + 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", () => { + 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.map((reference) => reference.id)).toEqual(["en-10365", "arcelormittal-sections"]); + 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); + expect(result.references.some((ref) => ref.id === "arcelormittal-sections")).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); + expect(result.references.some((ref) => ref.id === "arcelormittal-sections")).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 3903118..34a4843 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -9,13 +9,15 @@ type Handlers = { beam_bending: Handler; section_properties: Handler; bolt_strength: Handler; + interference_fit: Handler; spring_design: Handler; shaft_analysis: Handler; bearing_life: Handler; von_mises: Handler; + fatigue_analysis: Handler; unit_convert: Handler; material_lookup: Handler; - interference_fit: Handler; + section_catalog: Handler; }; let ctx: AppContext; @@ -32,19 +34,21 @@ function expectOk(response: Awaited>): ToolResult { } describe("tool registry", () => { - it("registers all ten tools", () => { + it("registers all twelve tools", () => { expect(listTools().sort()).toEqual( [ "beam_bending", "section_properties", "bolt_strength", + "interference_fit", "spring_design", "shaft_analysis", "bearing_life", "von_mises", + "fatigue_analysis", "unit_convert", "material_lookup", - "interference_fit", + "section_catalog", ].sort(), ); }); @@ -329,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(); diff --git a/tests/units.test.ts b/tests/units.test.ts index 8839b35..3a22ce6 100644 --- a/tests/units.test.ts +++ b/tests/units.test.ts @@ -135,3 +135,165 @@ 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); + } + }); +}); + +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"); + } + }); +});