diff --git a/.github/workflows/InteractiveTest.yml b/.github/workflows/InteractiveTest.yml
index 37b8b4555..23fbd7855 100644
--- a/.github/workflows/InteractiveTest.yml
+++ b/.github/workflows/InteractiveTest.yml
@@ -31,6 +31,7 @@ jobs:
run: |
dotnet build Sources/Tests/InteractiveWrapperUnitTests -c Release
dotnet build Sources/Tests/TerminalUnitTests -c Release
+ dotnet build Sources/Tests/McpUnitTests -c Release
- name: Test Interactive Wrapper
if: ${{ matrix.os != 'ubuntu-latest' }}
@@ -42,11 +43,20 @@ jobs:
run: |
dotnet test "Sources/Tests/TerminalUnitTests" -c Release
+ # The server amcli mcp runs. It builds against the library in this repository, so a change
+ # that moves an answer its documents describe, or a function the grammar gains or loses,
+ # fails here rather than after a release.
+ - name: Test MCP server
+ if: ${{ matrix.os != 'ubuntu-latest' }}
+ run: |
+ dotnet test "Sources/Tests/McpUnitTests" -c Release
+
- name: Test with coverage
if: ${{ matrix.os == 'ubuntu-latest' }}
run: |
dotnet test "Sources/Tests/InteractiveWrapperUnitTests" -c Release --collect:"XPlat Code Coverage" --results-directory ./coverage -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
dotnet test "Sources/Tests/TerminalUnitTests" -c Release --collect:"XPlat Code Coverage" --results-directory ./coverage -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
+ dotnet test "Sources/Tests/McpUnitTests" -c Release --collect:"XPlat Code Coverage" --results-directory ./coverage -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
- name: 'Send to codecov'
if: ${{ matrix.os == 'ubuntu-latest' }}
diff --git a/AGENTS.md b/AGENTS.md
index 62bd0e982..de48b5819 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -821,6 +821,7 @@ are short, and a stale one is worse than none — if you change what a file desc
| [`Contributing/Packaging.md`](Sources/AngouriMath/Docs/Contributing/Packaging.md) | which capabilities ship in the kernel package and which ship separately, and the four checkable clauses that decide. Read it before anything large lands in the kernel |
| [`WhatsNew/version_performance_control.md`](Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md) | the inter-version performance table, how to add a column, and when the CI performance baseline may be updated |
| `Sources/Analyzers/` | the custom analyzers, including the static-field one behind `[ConstantField]` |
+| [`Sources/MCP/README.md`](Sources/MCP/README.md) | the MCP server `amcli mcp` runs: what it offers an agent, and the invariants that look arbitrary in it and are not |
Anything added for the library's own purposes is not `public` — see
[`Contributing/coding_rules.md`](Sources/AngouriMath/Docs/Contributing/coding_rules.md). Nothing
diff --git a/Sources/.editorconfig b/Sources/.editorconfig
index d964644f0..74fff6705 100644
--- a/Sources/.editorconfig
+++ b/Sources/.editorconfig
@@ -720,6 +720,12 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed
[AngouriMath/Numerics/*.cs]
file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n
+[MCP/**.cs]
+file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n
+
+[Tests/McpUnitTests/**.cs]
+file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n
+
[Tests/UnitTests/Core/IntervalEvaluationTest.cs]
file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n
diff --git a/Sources/AngouriMath.sln b/Sources/AngouriMath.sln
index 8e196265e..007858d5b 100644
--- a/Sources/AngouriMath.sln
+++ b/Sources/AngouriMath.sln
@@ -53,6 +53,12 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AngouriMath.Terminal", "Ter
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AngouriMath.Terminal.Lib", "Terminal\AngouriMath.Terminal.Lib\AngouriMath.Terminal.Lib.fsproj", "{405E214B-17A6-83E0-90A1-4F8B7213954F}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MCP", "MCP", "{A3D7217B-225C-4020-980F-EBB8EAECADBA}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AngouriMath.Mcp", "MCP\AngouriMath.Mcp\AngouriMath.Mcp.csproj", "{50C3DF3D-BB3E-49D8-BC88-4C7BE573B51A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McpUnitTests", "Tests\McpUnitTests\McpUnitTests.csproj", "{0C73C9BD-C99C-486D-A058-EE269F02DB77}"
+EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Utils", "Utils", "{DC3A3E46-854E-403D-8FA5-561DF66277D4}"
ProjectSection(SolutionItems) = preProject
Utils\antlr_rerun.bat = Utils\antlr_rerun.bat
@@ -153,6 +159,14 @@ Global
{405E214B-17A6-83E0-90A1-4F8B7213954F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{405E214B-17A6-83E0-90A1-4F8B7213954F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{405E214B-17A6-83E0-90A1-4F8B7213954F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {50C3DF3D-BB3E-49D8-BC88-4C7BE573B51A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {50C3DF3D-BB3E-49D8-BC88-4C7BE573B51A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {50C3DF3D-BB3E-49D8-BC88-4C7BE573B51A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {50C3DF3D-BB3E-49D8-BC88-4C7BE573B51A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0C73C9BD-C99C-486D-A058-EE269F02DB77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0C73C9BD-C99C-486D-A058-EE269F02DB77}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0C73C9BD-C99C-486D-A058-EE269F02DB77}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0C73C9BD-C99C-486D-A058-EE269F02DB77}.Release|Any CPU.Build.0 = Release|Any CPU
{9E733914-18F8-CD3A-EDB8-F32FFEF4A3DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9E733914-18F8-CD3A-EDB8-F32FFEF4A3DA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9E733914-18F8-CD3A-EDB8-F32FFEF4A3DA}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -197,6 +211,8 @@ Global
{13409571-0ADC-B9C6-2EE7-44DF0B4AD43C} = {822E64E5-9BB9-4702-B704-18129D0BC9C7}
{E34FC29D-1046-0B02-E647-858434BFD9E0} = {7FCB69C7-EC31-4F61-BDD9-43B9DEED8750}
{405E214B-17A6-83E0-90A1-4F8B7213954F} = {7FCB69C7-EC31-4F61-BDD9-43B9DEED8750}
+ {50C3DF3D-BB3E-49D8-BC88-4C7BE573B51A} = {A3D7217B-225C-4020-980F-EBB8EAECADBA}
+ {0C73C9BD-C99C-486D-A058-EE269F02DB77} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53}
{9E733914-18F8-CD3A-EDB8-F32FFEF4A3DA} = {DC3A3E46-854E-403D-8FA5-561DF66277D4}
{FD30A551-F0D8-071A-9356-0BF0CDFB2FCC} = {D8022D2F-1DCE-4CE1-98DF-70B00D719D59}
{ABAD0619-30C7-242D-670F-2ECC908074A2} = {D8022D2F-1DCE-4CE1-98DF-70B00D719D59}
diff --git a/Sources/MCP/AngouriMath.Mcp/Analysis.cs b/Sources/MCP/AngouriMath.Mcp/Analysis.cs
new file mode 100644
index 000000000..0be5a3044
--- /dev/null
+++ b/Sources/MCP/AngouriMath.Mcp/Analysis.cs
@@ -0,0 +1,197 @@
+//
+// Copyright (c) 2019-2026 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using AngouriMath;
+using AngouriMath.Core;
+using static AngouriMath.Entity;
+
+namespace AngouriMath.Mcp;
+
+///
+/// Answers two questions the raw library does not: "what should I watch out for in this
+/// expression?" and "is this answer real, or merely correct?".
+///
+/// AngouriMath has no step engine — there is no derivation output anywhere in the library —
+/// so the honest substitute is to check a chain of working the caller supplies, and say
+/// which step broke. See .
+///
+internal static class Analysis
+{
+ // Spread across the real line, including negatives and a point near zero, because the
+ // whole point here is to FIND the places an expression stops being real or defined.
+ private static readonly Entity[] Probes =
+ ["-2.7", "-1.3", "-0.4", "0.6", "1.9", "3.2"];
+
+ public sealed record Caution(string Where, string Risk);
+
+ ///
+ /// Structural hazards, found by walking the tree. These are the classic ways an
+ /// expression means less than it appears to.
+ ///
+ public static List Hazards(Entity e)
+ {
+ var found = new List();
+
+ foreach (var node in e.Nodes)
+ {
+ switch (node)
+ {
+ case Divf:
+ var divisor = node.DirectChildren.Count > 1
+ ? node.DirectChildren[1].Stringize()
+ : "?";
+ found.Add(new Caution(node.Stringize(),
+ $"division: undefined where {divisor} = 0. Any cancellation of this " +
+ "term silently widens the domain unless a `provided` guard survives."));
+ break;
+
+ case Logf:
+ found.Add(new Caution(node.Stringize(),
+ "logarithm: real only for a positive argument. Combining " +
+ "ln(a)+ln(b) into ln(a*b) widens the domain and is a documented " +
+ "source of extraneous roots."));
+ break;
+
+ case Powf pow when pow.Exponent is not Number.Integer:
+ found.Add(new Caution(node.Stringize(),
+ "fractional or symbolic power: the principal branch is taken, so " +
+ "(-8)^(1/3) is a complex number, not -2. sqrt(x^2) is abs(x), not x."));
+ break;
+
+ case Arcsinf or Arccosf:
+ found.Add(new Caution(node.Stringize(),
+ "inverse trig: real only for an argument in [-1, 1], and the " +
+ "principal branch is returned — the full solution family is periodic."));
+ break;
+
+ case Factorialf:
+ found.Add(new Caution(node.Stringize(),
+ "factorial: defined via the gamma function, so it returns values for " +
+ "non-integers and poles at the negative integers."));
+ break;
+
+ case Absf:
+ found.Add(new Caution(node.Stringize(),
+ "absolute value: not differentiable at 0, and it hides a sign change " +
+ "that matters when solving."));
+ break;
+ }
+ }
+
+ return found;
+ }
+
+ /// Domain guards AngouriMath itself worked out, pulled out of the tree.
+ public static List Conditions(Entity simplified) =>
+ simplified.Nodes
+ .OfType()
+ .Select(p => p.Predicate.Stringize())
+ .Distinct()
+ .ToList();
+
+ public sealed record RealityReport(
+ List ComplexAt, List UndefinedAt, int Sampled);
+
+ ///
+ /// Where does this expression stop being a real number? A result can be perfectly
+ /// correct and still be useless as a length, a resistance or a time.
+ ///
+ public static RealityReport Reality(Entity e)
+ {
+ var complexAt = new List();
+ var undefinedAt = new List();
+ var variables = e.Vars.ToArray();
+ var sampled = 0;
+
+ // With no free variables there is a single value to inspect.
+ if (variables.Length == 0)
+ {
+ Inspect(e, "(constant)");
+ return new RealityReport(complexAt, undefinedAt, 1);
+ }
+
+ foreach (var probe in Probes)
+ {
+ var substituted = e;
+ foreach (var variable in variables)
+ substituted = substituted.Substitute(variable, probe);
+
+ sampled++;
+ Inspect(substituted, string.Join(" = ", variables.Select(v => v.Stringize())) +
+ " = " + probe.Stringize());
+ }
+
+ return new RealityReport(complexAt, undefinedAt, sampled);
+
+ void Inspect(Entity at, string label)
+ {
+ try
+ {
+ if (!at.EvaluableNumerical) { undefinedAt.Add(label); return; }
+
+ var value = at.EvalNumerical();
+ var real = value.RealPart.EDecimal.ToDouble();
+ var imaginary = value.ImaginaryPart.EDecimal.ToDouble();
+
+ if (double.IsNaN(real) || double.IsNaN(imaginary)) undefinedAt.Add(label);
+ else if (Math.Abs(imaginary) > 1e-12) complexAt.Add(label);
+ }
+ catch
+ {
+ undefinedAt.Add(label);
+ }
+ }
+ }
+
+ /// Force an expression to be real-valued: anything complex becomes NaN.
+ public static Entity AsReal(Entity e) =>
+ e.Replace(node => node.WithCodomain(Domain.Real));
+
+ public sealed record StepCheck(
+ int Index, string From, string To, bool? Equal, string Method, string? Difference);
+
+ ///
+ /// Check a chain of working, one step at a time, and report the first step that changes
+ /// the value.
+ ///
+ /// This is the useful form of "show me the steps". The library cannot produce a
+ /// derivation — but a model is perfectly good at PROPOSING one and unreliable at
+ /// EXECUTING it, so having it write the steps and having this check each transition
+ /// puts each side on the task it is actually good at. A wrong step is located, not just
+ /// detected.
+ ///
+ public static List CheckSteps(List steps)
+ {
+ var checks = new List();
+
+ for (var i = 1; i < steps.Count; i++)
+ {
+ var from = steps[i - 1];
+ var to = steps[i];
+
+ Entity difference;
+ try { difference = (from - to).Simplify(); }
+ catch { difference = from - to; }
+
+ if (Numeric.IsZero(difference))
+ {
+ checks.Add(new StepCheck(i, from.Stringize(), to.Stringize(),
+ true, "exact", null));
+ continue;
+ }
+
+ var numerically = Numeric.Equal(difference, 0);
+ checks.Add(new StepCheck(
+ i, from.Stringize(), to.Stringize(),
+ numerically,
+ numerically is null ? "inconclusive" : "numeric",
+ numerically == true ? null : difference.Stringize()));
+ }
+
+ return checks;
+ }
+}
diff --git a/Sources/MCP/AngouriMath.Mcp/AngouriMath.Mcp.csproj b/Sources/MCP/AngouriMath.Mcp/AngouriMath.Mcp.csproj
new file mode 100644
index 000000000..b1ae0d429
--- /dev/null
+++ b/Sources/MCP/AngouriMath.Mcp/AngouriMath.Mcp.csproj
@@ -0,0 +1,31 @@
+
+
+
+
+
+ net10.0
+ enable
+ AngouriMath.Mcp
+ true
+ true
+ true
+
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Sources/MCP/AngouriMath.Mcp/Classify.cs b/Sources/MCP/AngouriMath.Mcp/Classify.cs
new file mode 100644
index 000000000..3c9bc12f4
--- /dev/null
+++ b/Sources/MCP/AngouriMath.Mcp/Classify.cs
@@ -0,0 +1,144 @@
+//
+// Copyright (c) 2019-2026 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using AngouriMath;
+using static AngouriMath.Entity;
+
+namespace AngouriMath.Mcp;
+
+///
+/// Guesses which field a formula comes from, by reading its symbols.
+///
+/// This is inference from naming convention, not mathematics. `c` means the speed of light
+/// in relativity and a concentration in chemistry; `T` is temperature or a period. The tool
+/// says what it noticed and how strongly, and the caller decides. It is deliberately
+/// unwilling to claim certainty — a wrong confident label is worse than no label.
+///
+internal static class Classify
+{
+ private sealed record Field(
+ string Name,
+ string[] Strong, // symbols that are near-diagnostic of the field
+ string[] Weak, // symbols consistent with it but common elsewhere
+ string[] Structure); // "derivative", "integral", "trig", "complex", ...
+
+ private static readonly Field[] Fields =
+ [
+ new("special relativity", ["c"], ["m", "E", "v", "p", "t"], []),
+ new("Newtonian gravitation", ["G"], ["M", "m", "r", "F"], []),
+ new("classical mechanics", ["F", "a"], ["m", "v", "t", "x", "p", "g", "k"], []),
+ new("quantum mechanics", ["hbar", "h", "psi"], ["E", "nu", "lambda", "p", "m"],
+ ["complex"]),
+ new("electromagnetism / circuits", ["V", "I", "Z", "L", "C", "R"],
+ ["Q", "omega", "f", "t"], ["complex"]),
+ new("thermodynamics", ["S", "T"], ["P", "V", "n", "R", "k", "Q", "U"], []),
+ new("fluid dynamics", ["rho", "Re", "mu"], ["v", "p", "d", "A"], []),
+ new("statistics / probability", ["sigma", "mu"], ["n", "p", "x", "N"], []),
+ new("chemistry / kinetics", ["K", "Ea"], ["n", "c", "V", "T", "R"], ["log"]),
+ new("finance", ["PV", "FV", "APR"], ["P", "r", "n", "t", "i"], []),
+ new("signal processing", ["omega", "j"], ["t", "f", "T", "A"], ["complex", "trig"]),
+ new("geometry / mensuration", ["pi"], ["r", "h", "a", "b", "A", "V"], ["trig"]),
+ new("number theory", [], ["n", "k", "p", "m"], ["factorial", "integers-only"]),
+ new("analysis / calculus", [], ["x", "y", "t"], ["derivative", "integral", "limit"]),
+ new("optics", ["n1", "n2"], ["theta", "n", "d", "lambda"], ["trig"]),
+ ];
+
+ ///
+ /// Shapes distinctive enough to name outright. Each also names the field it implies:
+ /// a recognised form is much stronger evidence than loose symbol overlap, and without
+ /// this `n*R*T/V` ranked as circuits — R and V are strong circuit markers — while the
+ /// signature was correctly calling it the ideal gas law.
+ ///
+ private static readonly (string Fragment, string Says, string Field)[] Signatures =
+ [
+ ("m * c ^ 2", "mass-energy equivalence (E = mc^2)", "special relativity"),
+ ("c ^ 2 * m", "mass-energy equivalence (E = mc^2)", "special relativity"),
+ ("v ^ 2 / c ^ 2", "Lorentz factor — special relativity", "special relativity"),
+ ("G * M", "Newtonian gravitation", "Newtonian gravitation"),
+ ("h * nu", "photon energy (E = h*nu)", "quantum mechanics"),
+ ("sqrt(L * C)", "LC resonant frequency", "electromagnetism / circuits"),
+ ("2 * pi * sqrt", "a resonant period or LC/pendulum frequency", ""),
+ ("1 / 2 * m * v ^ 2", "kinetic energy", "classical mechanics"),
+ ("n * R * T", "ideal gas law", "thermodynamics"),
+ ("R * T * n", "ideal gas law", "thermodynamics"),
+ ("sin(x) / x", "the sinc function — diffraction and sampling", "signal processing"),
+ ];
+
+ public sealed record Guess(string Field, int Score, string Because);
+
+ public static (List Guesses, List Signatures, List Features)
+ Analyse(Entity e)
+ {
+ var symbols = e.Vars.Select(v => v.Stringize()).ToHashSet(StringComparer.Ordinal);
+ var features = Features(e);
+ var text = e.Stringize();
+
+ var matched = Signatures
+ .Where(s => text.Contains(s.Fragment, StringComparison.Ordinal))
+ .ToList();
+
+ var signatures = matched.Select(s => s.Says).Distinct().ToList();
+ var implied = matched
+ .Where(s => s.Field.Length > 0)
+ .Select(s => s.Field)
+ .ToHashSet(StringComparer.Ordinal);
+
+ var guesses = new List();
+ foreach (var field in Fields)
+ {
+ var strong = field.Strong.Where(symbols.Contains).ToArray();
+ var weak = field.Weak.Where(symbols.Contains).ToArray();
+ var structural = field.Structure.Where(features.Contains).ToArray();
+
+ var recognised = implied.Contains(field.Name);
+ var score = strong.Length * 3 + weak.Length + structural.Length * 2
+ + (recognised ? 10 : 0);
+ if (score == 0) continue;
+
+ // A field claimed on common symbols alone is barely evidence.
+ if (!recognised && strong.Length == 0 && structural.Length == 0 && weak.Length < 2)
+ continue;
+
+ var reasons = new List();
+ if (recognised) reasons.Add("matches a known formula shape");
+ if (strong.Length > 0) reasons.Add($"distinctive symbols {string.Join(", ", strong)}");
+ if (weak.Length > 0) reasons.Add($"also uses {string.Join(", ", weak)}");
+ if (structural.Length > 0) reasons.Add($"structure: {string.Join(", ", structural)}");
+
+ guesses.Add(new Guess(field.Name, score, string.Join("; ", reasons)));
+ }
+
+ return (guesses.OrderByDescending(g => g.Score).Take(4).ToList(), signatures, features);
+ }
+
+ private static List Features(Entity e)
+ {
+ var features = new List();
+ var numeric = true;
+
+ foreach (var node in e.Nodes)
+ {
+ switch (node)
+ {
+ case Derivativef: features.Add("derivative"); break;
+ case Integralf: features.Add("integral"); break;
+ case Limitf: features.Add("limit"); break;
+ case Sinf or Cosf or Tanf or Cotanf or Secantf or Cosecantf:
+ features.Add("trig"); break;
+ case Logf: features.Add("log"); break;
+ case Factorialf: features.Add("factorial"); break;
+ case Number.Complex complex when !complex.ImaginaryPart.EDecimal.IsZero:
+ features.Add("complex"); break;
+ }
+
+ if (node is Variable) numeric = false;
+ }
+
+ if (numeric && e.Vars.Count == 0) features.Add("integers-only");
+ return features.Distinct().ToList();
+ }
+}
diff --git a/Sources/MCP/AngouriMath.Mcp/Guard.cs b/Sources/MCP/AngouriMath.Mcp/Guard.cs
new file mode 100644
index 000000000..5bbdb5b7e
--- /dev/null
+++ b/Sources/MCP/AngouriMath.Mcp/Guard.cs
@@ -0,0 +1,120 @@
+//
+// Copyright (c) 2019-2026 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using AngouriMath;
+using AngouriMath.Core.Exceptions;
+
+namespace AngouriMath.Mcp;
+
+///
+/// Every call into the library goes through here, for three protections:
+///
+/// 1. Cancellation through MathS.Multithreading.SetLocalCancellationToken, which stops a
+/// runaway Simplify or Solve.
+/// 2. A dedicated thread with a 64 MB stack, abandoned rather than killed on timeout.
+/// Cancellation cannot rescue a stack overflow, which would take the server down with every
+/// request after it; the deep stack is what keeps a deep recursion from becoming one. .NET
+/// cannot abort a thread, so a case that hangs keeps its thread for the life of the process.
+/// 3. Screening the result for unevaluated nodes and for NaN, below.
+///
+internal static class Guard
+{
+ public const int DefaultTimeoutMs = 20_000;
+ private const int StackBytes = 64 * 1024 * 1024;
+
+ public readonly record struct Outcome(T? Value, string Status, string? Error)
+ {
+ public bool Ok => Status == "ok";
+ }
+
+ public static Outcome Run(Func work, int timeoutMs = DefaultTimeoutMs)
+ {
+ T? result = default;
+ Exception? failure = null;
+ using var cts = new CancellationTokenSource();
+
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ MathS.Multithreading.SetLocalCancellationToken(cts.Token);
+ result = work();
+ }
+ catch (Exception e)
+ {
+ failure = e;
+ }
+ }, StackBytes) { IsBackground = true };
+
+ thread.Start();
+
+ if (!thread.Join(timeoutMs))
+ {
+ cts.Cancel();
+ // Deliberately not joined again: the thread may be stuck in native/deep
+ // recursion. Abandon it rather than block the server.
+ return new Outcome(default, "timeout",
+ $"no answer within {timeoutMs} ms (the call was abandoned)");
+ }
+
+ if (failure is not null)
+ return new Outcome(default, StatusFor(failure), Describe(failure));
+
+ return new Outcome(result, "ok", null);
+ }
+
+ private static Exception Unwrap(Exception e) =>
+ e is AggregateException agg && agg.InnerException is not null ? agg.InnerException : e;
+
+ ///
+ /// A refusal is not a failure. Where the library knows it has no rule, it raises
+ /// NotSufficientlySupportedException, and that is reported as `declined`, the word this
+ /// server uses for an unevaluated integral too. `failed` keeps its meaning: bad input, or
+ /// something that genuinely went wrong.
+ ///
+ private static string StatusFor(Exception e) =>
+ Unwrap(e) is NotSufficientlySupportedException ? "declined" : "failed";
+
+ private static string Describe(Exception e)
+ {
+ var inner = Unwrap(e);
+ // An AngouriBugException is the library's internal assertion. Surfacing the type name
+ // tells the caller "library defect", not "bad input".
+ return $"{inner.GetType().Name}: {inner.Message}";
+ }
+
+ ///
+ /// Operators AngouriMath leaves in the tree when it cannot do the job. Finding one of
+ /// these in the result means "declined", not "solved".
+ ///
+ /// This MUST be tested against the RAW result, before any Simplify: an unevaluated
+ /// limit(...) simplifies to NaN, so simplifying first converts an honest decline into
+ /// what looks like a wrong answer.
+ ///
+ private static readonly string[] UnevaluatedMarkers =
+ ["integral(", "derivative(", "limit(", "limitleft(", "limitright("];
+
+ public static bool IsDeclined(string rawStringized)
+ {
+ foreach (var marker in UnevaluatedMarkers)
+ if (rawStringized.Contains(marker, StringComparison.Ordinal))
+ return true;
+ return false;
+ }
+
+ ///
+ /// A printed NaN in an answer is never legitimate output here: an integral or a
+ /// simplification that exists has none in it, so a NaN inside an otherwise plausible
+ /// answer, such as `NaN * x`, marks it wrong.
+ ///
+ /// Caveat worth knowing: `limit x->0 (1/x)` legitimately evaluates to NaN meaning "the
+ /// limit does not exist". The limit tool therefore reports that case distinctly rather
+ /// than calling it suspect.
+ ///
+ public static bool LooksLikeNaN(string rendered) =>
+ rendered.Contains("NaN", StringComparison.Ordinal);
+}
diff --git a/Sources/MCP/AngouriMath.Mcp/Matrices.cs b/Sources/MCP/AngouriMath.Mcp/Matrices.cs
new file mode 100644
index 000000000..5b99911bd
--- /dev/null
+++ b/Sources/MCP/AngouriMath.Mcp/Matrices.cs
@@ -0,0 +1,177 @@
+//
+// Copyright (c) 2019-2026 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using System.Text.Json.Nodes;
+using AngouriMath;
+using static AngouriMath.Entity;
+
+namespace AngouriMath.Mcp;
+
+///
+/// Matrix work, and symbolic eigenvalues built on top of it.
+///
+/// AngouriMath's matrices run on GenericTensor (as GenTensor<Entity, ...>), which has no
+/// eigen, SVD or decomposition of any kind. That does not matter here: for a symbolic system
+/// the right route to eigenvalues is the characteristic polynomial — det(A - lambda*I) — fed
+/// to the existing equation solver. Beyond 4x4 there is no closed form anyway (Abel-Ruffini),
+/// so a numerical eigensolver would not have been the answer.
+///
+internal static class Matrices
+{
+ public sealed record Parsed(Matrix? Value, List Warnings, string? Error);
+
+ /// Read a JSON array of arrays of expression strings into a matrix.
+ public static Parsed Parse(JsonNode? node, string label)
+ {
+ if (node is not JsonArray rows || rows.Count == 0)
+ return new Parsed(null, [], $"'{label}' must be a non-empty array of rows");
+
+ var warnings = new List();
+ var parsed = new List>();
+ var width = -1;
+
+ foreach (var row in rows)
+ {
+ if (row is not JsonArray cells)
+ return new Parsed(null, [], $"'{label}' must be an array of ARRAYS (rows)");
+
+ if (width < 0) width = cells.Count;
+ else if (cells.Count != width)
+ return new Parsed(null, [],
+ $"'{label}' is ragged: a row has {cells.Count} entries, expected {width}");
+
+ var built = new List();
+ foreach (var cell in cells)
+ {
+ var text = cell?.GetValue();
+ if (string.IsNullOrWhiteSpace(text))
+ return new Parsed(null, [], $"'{label}' contains an empty entry");
+
+ var outcome = Parsing.Parse(text, strict: false);
+ if (outcome.Entity is null)
+ return new Parsed(null, [], $"'{label}': could not parse '{text}' — {outcome.Error}");
+
+ built.Add(outcome.Entity);
+ warnings.AddRange(outcome.Warnings);
+ }
+ parsed.Add(built);
+ }
+
+ var grid = new Entity[parsed.Count, width];
+ for (var i = 0; i < parsed.Count; i++)
+ for (var j = 0; j < width; j++)
+ grid[i, j] = parsed[i][j];
+
+ return new Parsed(MathS.Matrix(grid), warnings.Distinct().ToList(), null);
+ }
+
+ /// Render a matrix as rows of strings, so the caller can consume it directly.
+ public static JsonArray Render(Matrix m)
+ {
+ var rows = new JsonArray();
+ for (var i = 0; i < m.RowCount; i++)
+ {
+ var row = new JsonArray();
+ for (var j = 0; j < m.ColumnCount; j++) row.Add(m[i, j].Stringize());
+ rows.Add(row);
+ }
+ return rows;
+ }
+
+ public sealed record Eigen(
+ Entity CharacteristicPolynomial,
+ Entity? Roots,
+ List DroppedGuards);
+
+ ///
+ /// Standard wording for what stripping a pivot guard means, so every tool that does it
+ /// says the same thing.
+ ///
+ public const string GuardNote =
+ "A routine that divides by pivots leaves a `provided` guard behind for each one. On " +
+ "a symbolic matrix these are artefacts of the algorithm rather than mathematics: " +
+ "det([[a,b],[c,d]]) is a*d - b*c for ALL a, and [[0,J],[J,0]] has eigenvalues +/-J " +
+ "including at J = 0. So they are removed here, and listed rather than silently dropped.";
+
+ ///
+ /// Remove pivot guards from a value, reporting what was removed.
+ ///
+ /// Order is load-bearing: simplify FIRST, strip second. The guard is what licenses the
+ /// cancellation — strip `provided not a = 0` from the raw Gaussian determinant and
+ /// Simplify can no longer reduce `a*(d*a - c*b)/a`, because a might be zero. Simplifying
+ /// while the guard is still attached yields `a*d - b*c`, and only then is the guard
+ /// itself redundant.
+ ///
+ public static (Entity Value, List Guards) Clean(Entity e)
+ {
+ var simplified = e;
+ try { simplified = e.Simplify(); }
+ catch { /* fall back to the original form */ }
+
+ var guards = Analysis.Conditions(simplified);
+ if (guards.Count == 0) return (simplified, guards);
+
+ return (Numeric.Strip(simplified), guards);
+ }
+
+ /// The same, entry by entry, for a matrix.
+ public static (Matrix Value, List Guards) Clean(Matrix m)
+ {
+ var guards = new List();
+ var grid = new Entity[m.RowCount, m.ColumnCount];
+
+ for (var i = 0; i < m.RowCount; i++)
+ for (var j = 0; j < m.ColumnCount; j++)
+ {
+ var (value, found) = Clean(m[i, j]);
+ grid[i, j] = value;
+ guards.AddRange(found);
+ }
+
+ return (MathS.Matrix(grid), guards.Distinct().ToList());
+ }
+
+ ///
+ /// Eigenvalues via the characteristic polynomial, which belongs in the library:
+ /// https://github.com/asc-community/AngouriMath/issues/1676
+ ///
+ /// The guard-dropping is not cosmetic. A determinant taken by dividing by pivots leaves a
+ /// `provided` guard for each one, and on a symbolic matrix those are artefacts of the
+ /// algorithm: they would make the characteristic polynomial of the Pauli X matrix
+ /// `lambda^2 - 1 provided not lambda = 0`, and the eigenvalues of [[0,J],[J,0]]
+ /// `{J provided not J = 0, -J provided not J = 0}`, where both values are valid at 0 too.
+ /// The library's determinant leaves none (`lambda^2 - 1`, `lambda^2 - J^2`); the stripping
+ /// stays, and reports what it dropped, for any routine that pivots.
+ ///
+ public static Eigen Compute(Matrix a, Variable lambda)
+ {
+ var n = a.RowCount;
+ var shifted = new Entity[n, n];
+ for (var i = 0; i < n; i++)
+ for (var j = 0; j < n; j++)
+ shifted[i, j] = i == j ? a[i, j] - lambda : a[i, j];
+
+ var raw = MathS.Matrix(shifted).Determinant
+ ?? throw new InvalidOperationException("determinant unavailable");
+
+ var (poly, guards) = Clean(raw);
+
+ Entity? roots;
+ try
+ {
+ // The solver reintroduces pivot guards of its own, so the result is cleaned
+ // again — stripping only the characteristic polynomial leaves eigenvalues
+ // reading 'J provided not J = 0'.
+ var (cleaned, solverGuards) = Clean(poly.SolveEquation(lambda).Simplify());
+ roots = cleaned;
+ guards.AddRange(solverGuards);
+ }
+ catch { roots = null; }
+
+ return new Eigen(poly, roots, guards.Distinct().ToList());
+ }
+}
diff --git a/Sources/MCP/AngouriMath.Mcp/Numeric.cs b/Sources/MCP/AngouriMath.Mcp/Numeric.cs
new file mode 100644
index 000000000..7964bf784
--- /dev/null
+++ b/Sources/MCP/AngouriMath.Mcp/Numeric.cs
@@ -0,0 +1,284 @@
+//
+// Copyright (c) 2019-2026 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using AngouriMath;
+using static AngouriMath.Entity;
+
+namespace AngouriMath.Mcp;
+
+///
+/// Tolerance-based numeric equality.
+///
+/// Why not just use MathS.UnsafeAndInternal.AreEqualNumerically? Because despite the name it
+/// compares with EXACT equality — the implementation is `if (evaled1 != evaled2 ...) return
+/// false` over arbitrary-precision decimals, with no tolerance at all. Any expression
+/// involving a transcendental therefore fails: ln(x) computed via two different but
+/// mathematically identical routes disagrees in the last digit and the check reports the
+/// expressions as different.
+///
+/// That is not a hypothetical. `∫ x*ln(x)` returns the correct antiderivative, which
+/// differentiates back to the correct integrand, and AreEqualNumerically still says false.
+/// So this compares with a relative tolerance instead.
+/// https://github.com/asc-community/AngouriMath/issues/1674
+///
+internal static class Numeric
+{
+ /// Deliberately positive reals: correct antiderivatives contain ln(x) and
+ /// abs(x), which are undefined or non-holomorphic on the negatives.
+ public static readonly Entity[] PositivePoints =
+ ["0.37", "1.31", "2.17", "0.59", "3.43"];
+
+ private const double RelativeTolerance = 1e-6;
+
+ ///
+ /// True / false / null, where null means "could not be evaluated at any sample point"
+ /// — which is not evidence either way and must not be reported as inequality.
+ ///
+ public static bool? Equal(Entity left, Entity right, Entity[]? points = null)
+ {
+ points ??= PositivePoints;
+
+ // `provided` guards are correct mathematics but do not evaluate to a number, so
+ // they are dropped for the comparison only.
+ var a = Strip(left);
+ var b = Strip(right);
+
+ var variables = a.Vars.Concat(b.Vars)
+ .Select(v => v.Name)
+ .Distinct()
+ .ToArray();
+
+ var usable = 0;
+
+ // Rotating the offset gives each variable a different value on each pass, so
+ // f(x, y) = x*y is not mistaken for f(x, y) = x^2.
+ for (var offset = 0; offset < points.Length; offset++)
+ {
+ var subA = a;
+ var subB = b;
+ for (var i = 0; i < variables.Length; i++)
+ {
+ var point = points[(i + offset) % points.Length];
+ var variable = MathS.Var(variables[i]);
+ subA = subA.Substitute(variable, point);
+ subB = subB.Substitute(variable, point);
+ }
+
+ var valueA = AsDouble(subA);
+ var valueB = AsDouble(subB);
+
+ // A point where either side is undefined tells us nothing; skip it rather than
+ // counting it as disagreement.
+ if (valueA is null || valueB is null) continue;
+
+ var (realA, imagA) = valueA.Value;
+ var (realB, imagB) = valueB.Value;
+
+ var separation = Math.Sqrt(
+ (realA - realB) * (realA - realB) + (imagA - imagB) * (imagA - imagB));
+ var scale = Math.Max(
+ 1.0,
+ Math.Max(Math.Sqrt(realA * realA + imagA * imagA),
+ Math.Sqrt(realB * realB + imagB * imagB)));
+
+ if (separation / scale > RelativeTolerance) return false;
+ usable++;
+ }
+
+ return usable == 0 ? null : true;
+ }
+
+ public sealed record Divergence(
+ double MaxAbsolute, string MaxAbsoluteAt,
+ double MaxRelative, string MaxRelativeAt,
+ double RootMeanSquare, int Sampled, int Skipped, int Complex);
+
+ ///
+ /// How far apart are two expressions across an interval?
+ ///
+ /// A different question from , and the one engineering actually asks:
+ /// not "is this approximation exact" — it is not — but "does it hold to 1e-6 across the
+ /// operating range, and where is it worst".
+ ///
+ public static Divergence? Diverge(
+ Entity left, Entity right, Variable variable, double from, double to, int samples)
+ {
+ var a = Strip(left);
+ var b = Strip(right);
+
+ var maxAbsolute = -1.0; var maxAbsoluteAt = "";
+ var maxRelative = -1.0; var maxRelativeAt = "";
+ var sumOfSquares = 0.0;
+ int used = 0, skipped = 0, complex = 0;
+
+ var step = samples > 1 ? (to - from) / (samples - 1) : 0.0;
+
+ for (var i = 0; i < samples; i++)
+ {
+ var at = samples > 1 ? from + step * i : from;
+ Entity point = at.ToString("R");
+
+ var valueA = AsDouble(a.Substitute(variable, point));
+ var valueB = AsDouble(b.Substitute(variable, point));
+
+ // A point where either side is undefined says nothing about agreement, so it is
+ // counted and skipped rather than scored as a mismatch.
+ if (valueA is null || valueB is null) { skipped++; continue; }
+
+ // ln and even roots return principal-branch complex values on the negatives
+ // rather than failing. Comparing their magnitudes is defensible, but a caller
+ // asking about a physical quantity needs to know the reference left the reals.
+ if (Math.Abs(valueA.Value.Imaginary) > 1e-12 ||
+ Math.Abs(valueB.Value.Imaginary) > 1e-12) complex++;
+
+ var difference = Math.Sqrt(
+ Math.Pow(valueA.Value.Real - valueB.Value.Real, 2) +
+ Math.Pow(valueA.Value.Imaginary - valueB.Value.Imaginary, 2));
+
+ var magnitude = Math.Sqrt(
+ valueA.Value.Real * valueA.Value.Real +
+ valueA.Value.Imaginary * valueA.Value.Imaginary);
+
+ if (difference > maxAbsolute)
+ {
+ maxAbsolute = difference;
+ maxAbsoluteAt = at.ToString("R");
+ }
+
+ // Relative error is meaningless where the reference is ~0, so it is scored only
+ // where there is something to be relative to.
+ if (magnitude > 1e-12)
+ {
+ var relative = difference / magnitude;
+ if (relative > maxRelative)
+ {
+ maxRelative = relative;
+ maxRelativeAt = at.ToString("R");
+ }
+ }
+
+ sumOfSquares += difference * difference;
+ used++;
+ }
+
+ if (used == 0) return null;
+
+ return new Divergence(
+ maxAbsolute, maxAbsoluteAt,
+ maxRelative < 0 ? 0 : maxRelative, maxRelativeAt,
+ Math.Sqrt(sumOfSquares / used), used, skipped, complex);
+ }
+
+ public static Entity Strip(Entity e) =>
+ e.Replace(node => node is Providedf provided ? provided.Expression : node);
+
+ /// A quadrature result: the value, and how far it can be trusted.
+ public readonly record struct Quadrature(double Value, double ErrorEstimate, int Digits);
+
+ // Step counts for the two runs. AngouriMath's rule is first order — measured, by
+ // doubling: the error of `∫[0,1] e^(x^2)` halves at every doubling of the step count
+ // rather than falling by four — so the gap between a run and its double IS the error of
+ // the coarser one, to a constant. Two runs at these counts cost about a third of a
+ // second together, which is inside the request budget; going further buys one digit per
+ // quadrupling and is not worth the wall clock.
+ private const int CoarseSteps = 2_000;
+ private const int FineSteps = 4_000;
+
+ ///
+ /// Numeric definite integration via `Entity.DefiniteIntegral`, run TWICE so that the
+ /// answer arrives with an error estimate rather than as a hundred digits of false
+ /// precision. https://github.com/asc-community/AngouriMath/issues/1675
+ ///
+ /// That precision is the whole reason this is not a one-line call. The library returns
+ /// an arbitrary-precision decimal whatever the step count, so `∫[0,1] e^(x^2)` prints as
+ /// 1.4666222366253148581140206684127892598 when the true value is 1.46265174590718 — a
+ /// figure that is wrong in its third decimal and reads as converged to thirty. Reporting
+ /// only the digits two runs agree on is exactly the job this server exists to do.
+ ///
+ /// Returns null when the integrand cannot be sampled: the rule evaluates at the
+ /// endpoints, so a singularity at either — `∫[0,1] sin(x)/x`, `∫[0,1] ln(x)`, both of
+ /// which converge — comes back NaN and must be declined rather than reported.
+ ///
+ public static Quadrature? TryDefiniteIntegral(Entity integrand, Variable variable,
+ double from, double to)
+ {
+ var coarse = Sample(integrand, variable, from, to, CoarseSteps);
+ var fine = Sample(integrand, variable, from, to, FineSteps);
+ if (coarse is not { } c || fine is not { } f) return null;
+
+ var error = Math.Abs(f - c);
+
+ // Digits justified by the error, relative to the magnitude of the answer: an error
+ // of 2e-4 on a value near 1.46 pins three decimals, and the fourth is noise. One
+ // digit is dropped on purpose — the estimate is itself an estimate.
+ var scale = Math.Max(Math.Abs(f), double.Epsilon);
+ var digits = error <= 0
+ ? 15
+ : (int)Math.Floor(-Math.Log10(error / scale)) - 1;
+ digits = Math.Clamp(digits, 0, 15);
+
+ return new Quadrature(f, error, digits);
+ }
+
+ private static double? Sample(Entity integrand, Variable variable,
+ double from, double to, int steps)
+ {
+ try
+ {
+ var value = integrand.DefiniteIntegral(variable, from, to, steps);
+ var real = value.RealPart.EDecimal.ToDouble();
+ var imaginary = value.ImaginaryPart.EDecimal.ToDouble();
+ if (double.IsNaN(real) || double.IsInfinity(real)) return null;
+ // A real integrand integrated over a real interval that comes back complex means
+ // the rule wandered off the domain; that is not an answer to report.
+ if (double.IsNaN(imaginary) || Math.Abs(imaginary) > 1e-12) return null;
+ return real;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ /// Real part as a double, or null when it is not numerically evaluable.
+ public static double? AsNumber(Entity e) => AsDouble(e)?.Real;
+
+ ///
+ /// As , but null rather than the real part when the value is
+ /// complex. An integration bound of `i` must not quietly become `0`.
+ ///
+ public static double? AsRealNumber(Entity e) =>
+ AsDouble(e) is { } value && Math.Abs(value.Imaginary) < 1e-12 ? value.Real : null;
+
+ /// Is this expression structurally zero once domain guards are removed?
+ public static bool IsZero(Entity e)
+ {
+ var bare = Strip(e).InnerSimplified;
+ return bare.Stringize() is "0" or "0.0" or "-0";
+ }
+
+ private static (double Real, double Imaginary)? AsDouble(Entity e)
+ {
+ if (!e.EvaluableNumerical) return null;
+
+ try
+ {
+ var value = e.EvalNumerical();
+ var real = value.RealPart.EDecimal.ToDouble();
+ var imaginary = value.ImaginaryPart.EDecimal.ToDouble();
+
+ if (double.IsNaN(real) || double.IsNaN(imaginary)) return null;
+ if (double.IsInfinity(real) || double.IsInfinity(imaginary)) return null;
+
+ return (real, imaginary);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/Sources/MCP/AngouriMath.Mcp/Parsing.cs b/Sources/MCP/AngouriMath.Mcp/Parsing.cs
new file mode 100644
index 000000000..d63090115
--- /dev/null
+++ b/Sources/MCP/AngouriMath.Mcp/Parsing.cs
@@ -0,0 +1,165 @@
+//
+// Copyright (c) 2019-2026 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using System.Collections.Concurrent;
+using System.Text.RegularExpressions;
+using AngouriMath;
+using HonkSharp.Functional;
+
+namespace AngouriMath.Mcp;
+
+///
+/// Parsing plus the two warnings that matter.
+///
+/// AngouriMath's parser is permissive in two ways that are silent and therefore dangerous
+/// when the caller is a language model rather than a human reading its own formula:
+///
+/// * a trailing number is an EXPONENT, not a factor: `x2` is x², `2(g+e)3` is 2(g+e)³.
+/// A model that names a variable `x2`, `v1` or `a0` — completely ordinary naming —
+/// gets it silently squared. (MathS.cs documents this on ExplicitParsingOnly.)
+/// * an unknown identifier becomes implicit multiplication: `im(z)` is the product
+/// `im * z`, not the imaginary part. The library refuses some such names by name —
+/// `trunc`, `lcm`, `erf`, `conjugate` and the arc- spellings of the inverse hyperbolics —
+/// so those fail loudly; the rest degrade with nothing said, because refusing every
+/// unknown name is refusing `a(b + c)`.
+///
+/// Both produce a valid parse of a DIFFERENT expression, which is the worst failure class
+/// available: no exception, plausible answer, wrong. Rather than force strict mode on
+/// everyone (which rejects the very common `2x`), the server parses permissively and always
+/// reports what it understood, with a warning when either pattern is present.
+///
+internal static class Parsing
+{
+ /// Either a parsed entity with its warnings, or an error string — never both.
+ public sealed record Outcome(Entity? Entity, List Warnings, string? Error);
+
+ ///
+ /// Whether the grammar reads name(...) as a call, rather than as the variable
+ /// name times a bracketed group. Asked of the parser itself, by parsing
+ /// name(x), so a name that becomes a function or stops being one is answered
+ /// correctly without anything here changing: a hand-kept list of names warns on correct
+ /// input when it lags behind the grammar, and that teaches a caller to ignore the channel.
+ ///
+ ///
+ /// A name the grammar refuses outright, or calls with the wrong number of arguments, also
+ /// counts as known: either way the input fails to parse, and the warnings below only look
+ /// at an input that parsed. Parsed permissively whatever the caller asked for, since the
+ /// answer is cached for every later request.
+ ///
+ internal static bool IsFunction(string name) => Calls.GetOrAdd(name, static candidate =>
+ {
+ using var _ = MathS.Settings.ExplicitParsingOnly.Set(false);
+ try
+ {
+ var x = MathS.Var("x");
+ return MathS.Parse(candidate + "(x)").Switch(
+ call => MathS.Parse(candidate).Switch(alone => call != alone * x, _ => true),
+ _ => true);
+ }
+ catch (Exception)
+ {
+ return true;
+ }
+ });
+
+ private static readonly ConcurrentDictionary Calls = new(StringComparer.Ordinal);
+
+ /// Names people reach for that this grammar spells differently, and that parse
+ /// as something else rather than failing. A name the library refuses outright is not
+ /// here: its own exception says more than this table could.
+ internal static readonly Dictionary Misspellings = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["setsubtraction"] = @"the infix '\' — write 'A \ B'",
+ ["elementin"] = "the infix 'in' — write 'x in A'",
+ ["impl"] = "'->' or 'implies' — write 'a -> b'",
+ ["re"] = "not in this grammar; there is no real-part function",
+ ["im"] = "not in this grammar; there is no imaginary-part function",
+ };
+
+ // The alphabet the grammar's VARIABLE rule accepts (Core/Antlr/AngouriMath.g): ASCII
+ // letters, Greek and Coptic, Greek Extended, Cyrillic. Both warnings below are lexical
+ // guesses at what the grammar will do, so they have to guess in the grammar's alphabet:
+ // `[A-Za-z]` would exempt every Greek and Cyrillic name from both checks, and `α2` would
+ // be squared with nothing said.
+ // Kept as escapes rather than literal Greek so the ranges can be read straight off the
+ // grammar. The string is verbatim, so C# passes the escapes through untouched and the
+ // regex engine is what interprets them.
+ private const string VariableChars = @"a-zA-Z\u0370-\u03FF\u1F00-\u1FFF\u0400-\u04FF";
+
+ // A digit directly after a LETTER is the trap: `x2` parses as x^2. Two things are NOT
+ // the trap. A digit after an underscore — `t_0` is a single variable named t_0, and is
+ // the conventional safe way to write a subscript. And the digits of an exponent: `1.5e3`
+ // is ONE number token, because EXPONENT is a fragment of NUMBER, so its `e` belongs to
+ // the literal rather than to a variable. Hence the second branch: an `e` IS a warnable
+ // identifier when no numeric literal precedes it, which is why bare `e3` (that is, e^3)
+ // still warns. False positives are how you teach a caller to ignore warnings.
+ private static readonly Regex TrailingDigit =
+ new($@"[{VariableChars}-[eE]]\d|(? new Outcome(entity, Warnings(source), null),
+ failure => new Outcome(null, [], failure.Reason.Switch(
+ unknown => $"could not parse: {unknown.Reason}",
+ missingOperator => $"missing operator: {missingOperator.Details}",
+ internalError => $"internal parser error: {internalError.Details}")));
+ }
+
+ private static List Warnings(string source)
+ {
+ var warnings = new List();
+ var calls = CallLike.Matches(source);
+
+ // A known function's own name is not an implicit power, even when it ends in a
+ // digit. `log2(8)` is the base-2 logarithm, and the `g2` inside it is
+ // not the `x2` trap — warning there fires on correct input, which is how a caller
+ // learns to ignore the channel. Spans rather than a special case for `log2` and
+ // `log10`, so the next function name carrying a digit needs no second fix.
+ var functionNameSpans = calls
+ .Where(m => IsFunction(m.Groups[1].Value))
+ .Select(m => (Start: m.Groups[1].Index, End: m.Groups[1].Index + m.Groups[1].Length))
+ .ToList();
+
+ var implicitPower = TrailingDigit.Matches(source).Any(m =>
+ !functionNameSpans.Any(s => m.Index >= s.Start && m.Index + m.Length <= s.End));
+
+ if (implicitPower)
+ warnings.Add(
+ "implicit-power: a number directly after an identifier is an EXPONENT, " +
+ "not a factor — 'x2' parses as x^2. Check the 'parsed' field; write 'x*2' " +
+ "if you meant multiplication, and avoid variable names ending in a digit.");
+
+ foreach (Match m in calls)
+ {
+ var name = m.Groups[1].Value;
+ if (IsFunction(name)) continue;
+
+ var hint = Misspellings.TryGetValue(name, out var spelling)
+ ? $" Use {spelling}."
+ : string.Empty;
+
+ warnings.Add(
+ $"unknown-function: '{name}' is not a function AngouriMath knows, so it was " +
+ $"read as a VARIABLE multiplied by the bracketed group, not as a call." +
+ hint + " Check the 'parsed' field.");
+ }
+
+ return warnings;
+ }
+}
diff --git a/Sources/MCP/AngouriMath.Mcp/Prompts.cs b/Sources/MCP/AngouriMath.Mcp/Prompts.cs
new file mode 100644
index 000000000..99aa3baeb
--- /dev/null
+++ b/Sources/MCP/AngouriMath.Mcp/Prompts.cs
@@ -0,0 +1,262 @@
+//
+// Copyright (c) 2019-2026 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using System.Text.Json.Nodes;
+
+namespace AngouriMath.Mcp;
+
+///
+/// User-invocable workflows, surfaced by MCP hosts as slash commands.
+///
+/// These exist because of the routing problem this whole server is shaped around: a model
+/// will not reach for a maths tool, since it is confident it can do algebra unaided. A
+/// prompt is the *user* reaching for it instead, which sidesteps the question entirely. They
+/// also cost nothing in tool-list context, which matters once a server has twenty-one tools.
+///
+/// Each one encodes a workflow already proven in test/scenarios.sh rather than inventing a
+/// new one.
+///
+internal static class Prompts
+{
+ private sealed record Argument(string Name, string Description, bool Required);
+
+ private sealed record Definition(
+ string Name,
+ string Description,
+ Argument[] Arguments,
+ Func, string> Build);
+
+ /// Advice repeated into every prompt, because it is what goes wrong.
+ private const string Discipline = """
+
+ While doing this:
+ - Read the `parsed` field of every response before trusting the answer. A trailing
+ digit is an exponent here, so a variable named `t0` silently becomes t^0, and an
+ unknown function name silently becomes a multiplication.
+ - Treat a `declined` or `unchanged` status as "no answer", never as the answer.
+ - If a call reports `verified: false` or `status: conflict`, stop and say so rather
+ than presenting the result.
+ - Say which tool produced each result. Do not present tool output as your own working.
+ """;
+
+ private static readonly Definition[] All =
+ [
+ new("verify-derivation",
+ "Check a chain of algebra step by step and identify exactly which step broke.",
+ [
+ new("working", "Your steps, one per line, each a complete expression.", true),
+ ],
+ get => $"""
+ Here is a derivation. Each line should be mathematically equal to the one
+ before it:
+
+ {get("working")}
+
+ Split it into successive expressions and pass them to `am_check_steps` in
+ order. Then tell me plainly whether the working is sound.
+
+ If a step is invalid, report which one, quote the `difference` the tool
+ returns, and explain in one sentence what went wrong there — a dropped term,
+ a sign, a bad cancellation. Do not re-derive the whole thing; the point is to
+ locate the error, not replace my working.
+ {Discipline}
+ """),
+
+ new("check-formula",
+ "Compare a formula as documented against a formula as implemented in code.",
+ [
+ new("documented", "The formula as written in the comment, spec or datasheet.", true),
+ new("implemented", "The expression as it actually appears in the code.", true),
+ ],
+ get => $"""
+ A formula appears two ways. As documented:
+
+ {get("documented")}
+
+ As implemented in the code:
+
+ {get("implemented")}
+
+ Use `am_verify_equal` to decide whether they are the same function.
+
+ If they differ, quote the `difference` — that names the discrepancy far more
+ precisely than a description does — and say which of the two is likely wrong.
+ If they agree, say so without hedging.
+
+ Then call `am_domain_check` on the implemented form and mention anything that
+ would bite in practice: a division that can vanish, a logarithm or square root
+ that leaves the reals, a value the hardware could plausibly produce that the
+ expression cannot handle.
+ {Discipline}
+ """),
+
+ new("derive-jacobian",
+ "Differentiate a model with respect to each state variable, with every partial verified.",
+ [
+ new("model", "The measurement or state function, e.g. 'sqrt(x^2 + y^2)'.", true),
+ new("variables", "Comma-separated variables to differentiate by, e.g. 'x,y'.", true),
+ ],
+ get => $"""
+ Build the Jacobian row for this model:
+
+ {get("model")}
+
+ with respect to: {get("variables")}
+
+ For each variable, call `am_differentiate`. Then verify each partial rather
+ than trusting it: integrate it back with `am_integrate` and check it returns
+ the original, or use `am_verify_equal` against a form you derive
+ independently. Say which check you used.
+
+ Present the result as a list of partials, then as a single row vector. Keep
+ the exact symbolic forms — do not convert to decimals. Hand-derived Jacobians
+ are where silent errors live for months, so flag anything you could not
+ verify rather than quietly including it.
+ {Discipline}
+ """),
+
+ new("analyse-approximation",
+ "Work out whether a fast approximation is sound and where it stops being good enough.",
+ [
+ new("exact", "The exact expression, e.g. '1/sqrt(x)'.", true),
+ new("approximation",
+ "The fast form, or one iteration of it, e.g. 'y*(3 - x*y^2)/2'.", true),
+ new("variable", "The variable, e.g. 'x'.", true),
+ new("range", "Operating range as 'from,to', e.g. '1,4'. Optional.", false),
+ ],
+ get => $"""
+ A fast approximation is being used in place of an exact expression.
+
+ exact: {get("exact")}
+ approximation: {get("approximation")}
+ variable: {get("variable")}
+ range: {Or(get("range"), "not stated — ask, or say what you assumed")}
+
+ Work out whether it is sound, in this order:
+
+ 1. **Is the iteration what it claims to be?** If the approximation is an
+ iterative refinement, it is probably Newton's method for some f. Work out
+ which f, then use `am_verify_equal` to confirm the update rule really is
+ `y - f(y)/f'(y)` for it. If it is not Newton, say what it is instead.
+
+ 2. **Derive the error term, do not guess it.** Substitute a perturbed value —
+ for a reciprocal-root style iteration that means `y = (1+e)/sqrt(x)` — with
+ `am_substitute`, then use `am_verify_equal` to pin the new error against
+ your candidate expression. The leading power of `e` is the order of
+ convergence, and that is the number that decides how many iterations are
+ needed. Quadratic means one iteration roughly squares the error.
+
+ 3. **Measure it.** Use `am_compare_numeric` across the range to get the worst
+ absolute and relative error and, more usefully, WHERE they occur. Sampling
+ says nothing between samples, so if the error curve could have a spike,
+ raise the sample count and say you did.
+
+ 4. **Account for the constants.** If a magic constant or a bit pattern is
+ involved, decompose it with `am_represent ieee754` and explain what it does
+ rather than repeating that it is magic. If the result is destined for fixed
+ point, run `am_represent fixed_point` and include the quantisation error
+ alongside the approximation error — they add up, and the smaller one is
+ often not the one people worry about.
+
+ Finish with a judgement: how many iterations for a stated tolerance, and the
+ input range over which that holds. If the approximation is unsound, or the
+ error is unbounded somewhere in range, lead with that.
+ {Discipline}
+ """),
+
+ new("solve-with-constraints",
+ "Solve an equation and keep only the physically meaningful branch.",
+ [
+ new("equation", "The equation, e.g. 'v = k*d^2 + m*d'.", true),
+ new("variable", "The variable to solve for.", true),
+ new("constraints",
+ "Physical constraints, comma-separated, e.g. 'd > 0'. Optional.", false),
+ ],
+ get => $"""
+ Solve this for {get("variable")}:
+
+ {get("equation")}
+
+ Physical constraints: {Or(get("constraints"), "none stated")}
+
+ Pass the equation and the constraints together to `am_solve` as a single
+ `constraints` list — it handles inequalities, so the solver itself discards
+ the branches that cannot occur. That is better than solving first and
+ filtering afterwards.
+
+ If no constraints were given, solve unconstrained, then tell me which roots
+ are physically implausible and what constraint would exclude them. A negative
+ length, a complex time, a root outside the measurable range: mathematically
+ correct and useless. Say so explicitly rather than presenting every root as
+ an answer.
+ {Discipline}
+ """),
+ ];
+
+ private static string Or(string value, string fallback) =>
+ string.IsNullOrWhiteSpace(value) ? fallback : value;
+
+ public static JsonArray List()
+ {
+ var listed = new JsonArray();
+ foreach (var prompt in All)
+ {
+ var arguments = new JsonArray();
+ foreach (var argument in prompt.Arguments)
+ arguments.Add(new JsonObject
+ {
+ ["name"] = argument.Name,
+ ["description"] = argument.Description,
+ ["required"] = argument.Required,
+ });
+
+ listed.Add(new JsonObject
+ {
+ ["name"] = prompt.Name,
+ ["description"] = prompt.Description,
+ ["arguments"] = arguments,
+ });
+ }
+ return listed;
+ }
+
+ /// Null when the name is unknown; throws when a required argument is missing.
+ public static JsonObject? Get(string name, JsonObject? arguments)
+ {
+ var prompt = All.FirstOrDefault(p => p.Name == name);
+ if (prompt is null) return null;
+
+ string Read(string key) =>
+ arguments?[key]?.GetValue() ?? string.Empty;
+
+ var missing = prompt.Arguments
+ .Where(a => a.Required && string.IsNullOrWhiteSpace(Read(a.Name)))
+ .Select(a => a.Name)
+ .ToArray();
+
+ if (missing.Length > 0)
+ throw new ArgumentException(
+ $"prompt '{name}' requires: {string.Join(", ", missing)}");
+
+ return new JsonObject
+ {
+ ["description"] = prompt.Description,
+ ["messages"] = new JsonArray
+ {
+ new JsonObject
+ {
+ ["role"] = "user",
+ ["content"] = new JsonObject
+ {
+ ["type"] = "text",
+ ["text"] = prompt.Build(Read),
+ },
+ },
+ },
+ };
+ }
+}
diff --git a/Sources/MCP/AngouriMath.Mcp/Representations.cs b/Sources/MCP/AngouriMath.Mcp/Representations.cs
new file mode 100644
index 000000000..e48aff4e8
--- /dev/null
+++ b/Sources/MCP/AngouriMath.Mcp/Representations.cs
@@ -0,0 +1,165 @@
+//
+// Copyright (c) 2019-2026 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using AngouriMath;
+using PeterO.Numbers;
+using static AngouriMath.Entity;
+
+namespace AngouriMath.Mcp;
+
+///
+/// How a number is *encoded*, as opposed to what it equals.
+///
+/// This is squarely adapter work rather than algebra, which is why it lives here: a Q15
+/// coefficient, the bits of a double, and a phasor are all the same number wearing different
+/// clothes, and the question is always "what does the machine actually hold, and what did
+/// that cost me". AngouriMath answers none of these because none of them are computer
+/// algebra.
+///
+internal static class Representations
+{
+ // ------------------------------------------------------------ fixed point
+
+ public sealed record FixedPoint(
+ EInteger Raw, Entity Represented, EDecimal Error, EDecimal RelativeError,
+ bool Saturated, EInteger Min, EInteger Max, EDecimal Resolution);
+
+ ///
+ /// Quantise to Q-format. `totalBits` is the machine word (16 for a Q15 coefficient in an
+ /// int16), `fractionBits` how many of them sit after the binary point.
+ ///
+ /// Saturation is reported rather than silently wrapped: on most processors a coefficient
+ /// that does not fit is a bug you want to hear about at design time, not a value that
+ /// quietly becomes its own negative at run time.
+ ///
+ public static FixedPoint ToFixed(EDecimal value, int totalBits, int fractionBits, bool signed)
+ {
+ var scale = EInteger.FromInt32(2).Pow(fractionBits);
+ var scaled = value.Multiply(EDecimal.FromEInteger(scale));
+
+ // Round to nearest, ties to even — what a decent toolchain does, and it avoids the
+ // upward bias that ties-away introduces across a table of coefficients.
+ var raw = scaled.RoundToIntegerNoRoundedFlag(
+ EContext.ForRounding(ERounding.HalfEven)).ToEInteger();
+
+ var min = signed ? -EInteger.FromInt32(2).Pow(totalBits - 1) : EInteger.Zero;
+ var max = signed
+ ? EInteger.FromInt32(2).Pow(totalBits - 1) - EInteger.One
+ : EInteger.FromInt32(2).Pow(totalBits) - EInteger.One;
+
+ var saturated = raw < min || raw > max;
+ if (raw < min) raw = min;
+ if (raw > max) raw = max;
+
+ // The represented value is exact: an integer over a power of two is a rational, so
+ // there is no second rounding here.
+ Entity represented = MathS.Numbers.CreateRational(raw, scale);
+
+ var back = EDecimal.FromEInteger(raw).Divide(
+ EDecimal.FromEInteger(scale), EContext.Decimal128);
+ var error = value.Subtract(back);
+ var relative = value.IsZero
+ ? EDecimal.Zero
+ : error.Divide(value, EContext.Decimal128).Abs();
+
+ var resolution = EDecimal.One.Divide(
+ EDecimal.FromEInteger(scale), EContext.Decimal128);
+
+ return new FixedPoint(raw, represented, error.Abs(), relative,
+ saturated, min, max, resolution);
+ }
+
+ public static Entity FromFixed(EInteger raw, int fractionBits) =>
+ MathS.Numbers.CreateRational(raw, EInteger.FromInt32(2).Pow(fractionBits));
+
+ // ------------------------------------------------------------ IEEE 754
+
+ public sealed record Ieee754(
+ string Bits, string Hex, int Sign, int RawExponent, int UnbiasedExponent,
+ string MantissaHex, string ExactValue, EDecimal Error, string Classification);
+
+ ///
+ /// What a float or double actually holds. The `ExactValue` is the point: 0.1 is not 0.1,
+ /// and printing the exact decimal a double stores settles arguments that explanations do
+ /// not.
+ ///
+ public static Ieee754 Decompose(EDecimal value, bool single)
+ {
+ if (single)
+ {
+ var f = (float)value.ToDouble();
+ var bits = BitConverter.SingleToInt32Bits(f);
+ var sign = (bits >> 31) & 1;
+ var exponent = (bits >> 23) & 0xFF;
+ var mantissa = bits & 0x7FFFFF;
+
+ return Build(
+ Convert.ToString((uint)bits, 2).PadLeft(32, '0'),
+ $"0x{(uint)bits:X8}", sign, exponent, exponent == 0 ? -126 : exponent - 127,
+ $"0x{mantissa:X6}", EDecimal.FromSingle(f), exponent, 0xFF, mantissa, value);
+ }
+ else
+ {
+ var d = value.ToDouble();
+ var bits = BitConverter.DoubleToInt64Bits(d);
+ var sign = (int)((bits >> 63) & 1);
+ var exponent = (int)((bits >> 52) & 0x7FF);
+ var mantissa = bits & 0xFFFFFFFFFFFFFL;
+
+ return Build(
+ Convert.ToString((long)bits, 2).PadLeft(64, '0'),
+ $"0x{(ulong)bits:X16}", sign, exponent, exponent == 0 ? -1022 : exponent - 1023,
+ $"0x{mantissa:X13}", EDecimal.FromDouble(d), exponent, 0x7FF, mantissa, value);
+ }
+
+ static Ieee754 Build(
+ string binary, string hex, int sign, int rawExponent, int unbiased,
+ string mantissaHex, EDecimal exact, int exponentField, int exponentMax,
+ long mantissa, EDecimal original)
+ {
+ var classification =
+ exponentField == exponentMax
+ ? (mantissa == 0 ? "infinity" : "NaN")
+ : exponentField == 0
+ ? (mantissa == 0 ? "zero" : "subnormal — reduced precision")
+ : "normal";
+
+ var error = classification is "normal" or "subnormal — reduced precision"
+ ? exact.Subtract(original).Abs()
+ : EDecimal.Zero;
+
+ return new Ieee754(binary, hex, sign, rawExponent, unbiased, mantissaHex,
+ exact.ToString(), error, classification);
+ }
+ }
+
+ // ------------------------------------------------------------ polar form
+
+ public sealed record Polar(Entity Magnitude, Entity Phase, Entity PhaseDegrees);
+
+ ///
+ /// Rectangular to polar, kept symbolic where it can be: 1+i gives sqrt(2) and pi/4, not
+ /// 1.414 and 0.785.
+ ///
+ public static Polar ToPolar(Entity real, Entity imaginary)
+ {
+ var magnitude = MathS.Sqrt(real * real + imaginary * imaginary).Simplify();
+
+ // arctan alone loses the quadrant, so the sign of the real part is folded back in.
+ // Without this, -1-i and 1+i report the same phase.
+ var phase = MathS.Arctan(imaginary / real);
+ var adjusted = Numeric.AsNumber(real) is { } r && r < 0
+ ? (Numeric.AsNumber(imaginary) is { } im && im < 0 ? phase - MathS.pi : phase + MathS.pi)
+ : phase;
+
+ var simplified = adjusted.Simplify();
+ return new Polar(magnitude, simplified, (simplified * 180 / MathS.pi).Simplify());
+ }
+
+ public static Entity FromPolar(Entity magnitude, Entity phase) =>
+ (magnitude * MathS.Cos(phase) + magnitude * MathS.Sin(phase) * MathS.i).Simplify();
+}
diff --git a/Sources/MCP/AngouriMath.Mcp/Resources.cs b/Sources/MCP/AngouriMath.Mcp/Resources.cs
new file mode 100644
index 000000000..998b98880
--- /dev/null
+++ b/Sources/MCP/AngouriMath.Mcp/Resources.cs
@@ -0,0 +1,277 @@
+//
+// Copyright (c) 2019-2026 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using System.Text.Json.Nodes;
+
+namespace AngouriMath.Mcp;
+
+///
+/// Three documents the model can read. They cost nothing in tool-list context, and the
+/// reliability one tells the model when to trust a result and when to expect a decline.
+///
+///
+/// ResourcesTest checks the syntax document against the grammar, so a function the grammar
+/// gains or loses fails the build until the list here says so.
+///
+internal static class Resources
+{
+ public static JsonArray List() =>
+ [
+ new JsonObject
+ {
+ ["uri"] = "angourimath://syntax",
+ ["name"] = "AngouriMath expression syntax",
+ ["description"] = "How to write expressions for this server, including the two " +
+ "parsing traps that silently change meaning. Read before " +
+ "composing a non-trivial expression.",
+ ["mimeType"] = "text/markdown",
+ },
+ new JsonObject
+ {
+ ["uri"] = "angourimath://curiosities",
+ ["name"] = "Verified mathematical curiosities",
+ ["description"] = "Famous results and coincidences, each with the exact " +
+ "expression to reproduce it on this server. Doubles as a " +
+ "self-test corpus.",
+ ["mimeType"] = "text/markdown",
+ },
+ new JsonObject
+ {
+ ["uri"] = "angourimath://reliability",
+ ["name"] = "Measured reliability by problem class",
+ ["description"] = "Which operations are trustworthy and which are known to " +
+ "decline or mislead, as the library's own harnesses measure them.",
+ ["mimeType"] = "text/markdown",
+ },
+ ];
+
+ public static string? Read(string uri) => uri switch
+ {
+ "angourimath://syntax" => Syntax,
+ "angourimath://curiosities" => Curiosities,
+ "angourimath://reliability" => Reliability,
+ _ => null,
+ };
+
+ private const string Syntax = """
+ # AngouriMath expression syntax
+
+ Plain text. `x^2 + 3*x - 1`, `sin(x)/x`, `sqrt(2)`, `e^x`, `+oo` / `-oo` for infinity.
+
+ ## Two traps that change the meaning of your expression without any error
+
+ **1. A number directly after an identifier is an EXPONENT, not a factor.**
+
+ | You write | It means |
+ |---|---|
+ | `x2` | x² |
+ | `2x` | 2·x |
+ | `2(g + e)3` | 2·(g + e)³ |
+
+ So a variable named `x2`, `v1` or `a0` is silently squared / raised. Avoid variable
+ names ending in a digit; write `x_2` or `xa` instead. Always read the `parsed` field
+ that every response returns.
+
+ **2. An unknown function name becomes a multiplication.**
+
+ `im(z)` is not a function here — it is the product `im * z`. The parse succeeds and
+ produces a different expression. Some names are refused outright, but not every
+ unknown name can be: refusing them all would refuse `a(b + c)`.
+
+ Functions:
+
+ - trigonometric: `sin cos tan cotan sec cosec`, also spelled `cot csc`, and their
+ inverses `arcsin arccos arctan arccotan arcsec arccosec`, also spelled
+ `asin acos atan acot acotan arccot asec acsc acosec arccsc`
+ - hyperbolic: `sinh cosh tanh cotanh sech cosech`, also spelled
+ `sh ch th cth coth sch csch`, and their inverses as *area* functions:
+ `arsinh arcosh artanh arcotanh arsech arcosech`, also spelled
+ `asinh acosh atanh acotanh acoth asech acosech acsch arsh arch arth arcth arcoth
+ arsch arcsch` — **not** `arcsinh`, which is refused
+ - `ln log log2 log10 exp pow sqrt cbrt sqr abs signum sign sgn`
+ - `floor ceil ceiling round min max gcd lcm`
+ - `gamma factorial binomial phi prime valuation`
+ - special functions: `erf erfc erfi Ei li Si Ci Shi Chi`
+ - sets: `card powerset union intersection complement image preimage domain aleph
+ iverson`
+ - `derivative integral limit limitleft limitright sum product argmax argmin piecewise
+ apply lambda`
+
+ ## Spellings worth knowing (verified against the grammar, not guessed)
+
+ | You might write | Reality |
+ |---|---|
+ | `arcsinh(x)` | **refused.** The inverse hyperbolics are area functions: write `arsinh`, `asinh` or `arsh` |
+ | `trunc`, `conjugate` | refused by name — the library has neither |
+ | `im(z)`, `re(z)` | **silently become `im * z`.** There is no real- or imaginary-part function |
+ | `elementin(x, A)`, `setsubtraction(A, B)`, `impl(a, b)` | **silently become products.** Use the infix `x in A`, `A \ B`, `a -> b` |
+ | `union(A, B)`, `intersection(A, B)` | refused: both are indexed, `union(A_i, i in I)`. For two sets write `A \/ B` and `A /\ B` |
+ | `7 mod 3` | `mod` is FLOORED: `-7 mod 3` is `2`, not `-1` |
+ | `7 % 3` | a parse error. `%` stays free to mean percent |
+ | `mod` as a variable name | a parse error: `mod` is a keyword. Rename it |
+ | `log(2, 8)` | `3` — base first, then argument |
+
+ The "silently become" cases are the dangerous ones: they parse, and they mean
+ something else. Anything else followed by `(` gets a warning.
+
+ ## Statements
+
+ `=` is equality (`x^2 = 4`), and `>`, `<`, `>=`, `<=` are comparisons. Combine with
+ `and`, `or`, `not`, `xor`, `implies`. `am_solve` accepts these, so you can solve
+ under constraints: `['x^2 = 4', 'x > 0']` yields `2`.
+
+ ## LaTeX
+
+ LaTeX is OUTPUT only. This server cannot parse LaTeX input — convert
+ `\frac{a}{b}` to `a/b` yourself before calling.
+ """;
+
+ private const string Curiosities = """
+ # Curiosities, each verified on this server
+
+ Every entry below was checked here, not copied from memory. The expression given is
+ exactly what to run — so this doubles as a self-test corpus. If one of these stops
+ reproducing, something regressed.
+
+ ## Exact identities
+
+ | Result | Run |
+ |---|---|
+ | Euler's identity, exactly 0 — not 1e-16 | `am_evaluate e^(i*pi) + 1` |
+ | Machin's formula: how π was computed to 100 digits by hand in 1706 | `am_verify_equal 4*arctan(1/5) - arctan(1/239) vs pi/4` |
+ | Two arctans make a right angle's eighth | `am_verify_equal arctan(1/2) + arctan(1/3) vs pi/4` |
+ | Golden ratio is a pentagon in disguise | `am_verify_equal (1+sqrt(5))/2 vs 2*cos(pi/5)` |
+ | φ from its defining property | `am_solve ['x^2 = x + 1', 'x > 0'] for x` |
+ | Ramanujan's taxicab: 1729 two ways | `am_verify_equal 1^3 + 12^3 vs 9^3 + 10^3` |
+
+ ## A machine-checked proof that 22/7 > π
+
+ The integrand is positive on (0,1), so the integral is positive — which proves the
+ inequality:
+
+ 1. `am_integrate x^4*(1-x)^4/(1+x^2) dx` (verifies by differentiating back)
+ 2. `am_evaluate 1/7 - 4/6 + 1 - 4/3 + 4 - 4*arctan(1)` → **exactly `22/7 - pi`**
+
+ ## Near misses — where floating point would lie to you
+
+ | Result | Run |
+ |---|---|
+ | Homer Simpson's Fermat counterexample. Agrees to **ten** significant figures — exactly a pocket calculator's width, which is the joke | `am_evaluate 3987^12 + 4365^12 - 4472^12` → 1211886809373872630985912112862690, not 0 |
+ | 42 as a sum of three cubes (Booker & Sutherland, 2019). Each term is ~5e50, so a double returns pure noise | `am_evaluate (-80538738812075974)^3 + 80435758145817515^3 + 12602123297335631^3` → 42 |
+ | 355/113, the best simple approximation to π (Milü, 5th century) | `am_evaluate 355/113 - pi digits 20` → 2.67e-7 |
+ | Ramanujan's quartic approximation to π | `am_evaluate (2143/22)^(1/4) - pi digits 20` → -1.01e-9 |
+ | One term of Ramanujan's 1/π series already gives 8 digits | `am_evaluate 9801/(2*sqrt(2)*1103) - pi digits 20` → 7.6e-8 |
+ | e^π − π is almost exactly 20, for no known reason | `am_evaluate e^pi - pi digits 20` → 19.999099979189475768 |
+
+ ## 42
+
+ | Result | Run |
+ |---|---|
+ | Adams' "six by nine" is true in base 13 | `am_represent base 54 to_base 13` → 42 |
+ | 42 = 101010, a perfect alternating bit pattern | `am_represent base 42 to_base 2` |
+ | The 5th Catalan number — counts the ways to parenthesise six factors | `am_evaluate 10! / (6! * 5!)` |
+ | 6¹ + 6², and also the sum of the first six even numbers | `am_evaluate 6^1 + 6^2` |
+ | The rainbow really is at 42°: minimise deviation through a raindrop | `am_solve ['(4/3)^2 - 1 = 3*c^2','c > 0'] for c` → `sqrt(21)/9`, giving 42.03° |
+
+ ## Ramanujan's constant, which this build gets right
+
+ `e^(pi*sqrt(163))` is `262537412640768743.99999999999925007...`, sitting 7.5e-13
+ below an integer for a reason from class field theory. Ask for enough digits and the
+ near-miss reproduces.
+
+ ## Where the answer is honest but not what you asked
+
+ - `sqrt(x^2)` is left as written rather than reduced to `abs(x)`. It is not reduced
+ to `x` either, which would be wrong for every negative.
+
+ ## Beyond this server
+
+ No infinite series: `sum` adds up finitely many terms, and an infinite sum is left
+ unevaluated, so Basel (π²/6) and the Leibniz series cannot be evaluated here. Worth
+ knowing anyway — truncating Leibniz at
+ 500,000 terms gives
+ `3.14159065358979324046264338326950` against π's
+ `3.14159265358979323846264338327950`: nearly every digit correct, with isolated
+ wrong ones. Those errors are not noise — they are the Euler numbers.
+
+ """;
+
+ private const string Reliability = """
+ # Measured reliability
+
+ ## What this server runs against
+
+ The library it ships with: `amcli mcp` is built and released with the library, so an
+ answer here is the library's answer at this version.
+
+ What follows is a snapshot. If something contradicts it, trust the tool's own `status`
+ and `verified` fields over this document, and say so.
+
+ The library measures itself on every change. Its CasBench harness runs a corpus of
+ problems with known answers, drawn from SymPy's test suite, the Rubi integration suite
+ and Gruntz's thesis, and fails on a wrong answer. Answers are not trusted on their
+ face: integrals are checked by differentiating back, equation roots by substituting
+ them in.
+
+ ## Trustworthy
+
+ - **Equations**: linear, quadratic, cubic, quartic, higher polynomial, rational,
+ radical, trigonometric, exponential, logarithmic, absolute-value and transcendental.
+ Every root is checked by substituting it back.
+ - **Derivatives**: fast and dependable. The PropCheck harness compares them with a
+ difference quotient.
+ - **Integrals**: table forms, linearity, f(ax+b), u-substitution, by parts (including
+ cyclic), arctan and arcsin forms, partial fractions, trig powers, trig substitution.
+ - **Limits**: including Gruntz-class, 0/0, ∞/∞, 1^∞, ∞−∞, and factorial asymptotics:
+ `lim x→∞ (x!/x^x)^(1/x)` is `1/e`, by Stirling.
+
+ ## Integrals without an elementary antiderivative
+
+ `∫ e^x/x`, `∫ e^(x^2)`, `∫ sin(x)/x` and `∫ 1/ln(x)` have none, and are answered with
+ the special functions that name them: `Ei(x)`, `sqrt(pi)/2 * erfi(x)`, `Si(x)` and
+ `li(x)`. They are verified by differentiating back like any other answer.
+
+ ## Expect a decline (this is correct behaviour, not a bug)
+
+ - `∫ x^x` and `∫ sqrt(1 + x^3)`: no closed form in the functions the library has;
+ the second is elliptic. Do not retry or reword.
+
+ A decline from `am_integrate` with `from` and `to` still carries a
+ `numeric_definite_value` for that one interval, rounded to the digits it is good for
+ — roughly four. It is quadrature, not an antiderivative, and it is not verified by
+ differentiating back.
+
+ ## Where results mislead
+
+ - **`am_verify_equal` decides on POSITIVE real points**, because correct
+ antiderivatives are full of `ln(x)` and `abs(x)`. So `sqrt(x^2)` against `x` comes
+ back `equal: true` — true on the positives, false at every negative. It is not
+ silent about it: a second pass across the whole real line adds a note saying the
+ two agree only for positive inputs. **Read the note before repeating the verdict.**
+ - **Simplify is not canonical**: `sqrt(12)+sqrt(27)` comes back as `sqrt(3)*5` while
+ `5*sqrt(3)` is left alone. Two different-looking outputs can be equal — use
+ `am_verify_equal` rather than comparing strings.
+ - **A `status` of `unchanged` means no progress, not "already simplest".**
+ - **Output tidiness** varies. Results are correct but not always in the form a human
+ would write. Pass `alternatives: true` to `am_simplify` and pick a nicer form.
+ - **Nonlinear systems** can come back with no solution when one exists.
+
+ ## How to talk about a result
+
+ State the status you were given. If a tool returns `declined`, say the library has no
+ rule for it rather than presenting the unevaluated form as an answer. If it returns
+ `unchanged`, say it made no progress. If `verified` is false or a `conflict` is
+ reported, do not use the result. Say which tool produced a number rather than
+ implying you worked it out.
+
+ ## Soundness note
+
+ Unlike some CAS libraries, this one tracks domain conditions: `(x^2-1)/(x-1)`
+ simplifies to `x + 1 provided not x - 1 = 0`, rather than an unconditional `x + 1`
+ that is wrong at x = 1. The `provided` clause is a feature — preserve it.
+ """;
+}
diff --git a/Sources/MCP/AngouriMath.Mcp/SelfTest.cs b/Sources/MCP/AngouriMath.Mcp/SelfTest.cs
new file mode 100644
index 000000000..913e7af76
--- /dev/null
+++ b/Sources/MCP/AngouriMath.Mcp/SelfTest.cs
@@ -0,0 +1,178 @@
+//
+// Copyright (c) 2019-2026 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using AngouriMath;
+using AngouriMath.Extensions;
+using static AngouriMath.Entity;
+
+namespace AngouriMath.Mcp;
+
+///
+/// `amcli mcp --selftest`: does this install work, and does the library still behave the way
+/// this server's documents say it does?
+///
+/// Two categories, and the distinction matters. Identities must hold; if one fails, the install
+/// or the library is broken and the exit code says so. Documented defects are only reported: one
+/// that stops reproducing is good news rather than a failure, but it means the documents describe
+/// a defect the library no longer has. The tests in McpUnitTests fail on either, so a change to
+/// the library that fixes one fails the build here until the documents say so too.
+///
+internal static class SelfTest
+{
+ private static bool IsZero(string expression)
+ {
+ var outcome = Parsing.Parse(expression);
+ return outcome.Entity is not null && Numeric.IsZero(outcome.Entity.Simplify());
+ }
+
+ private static string Value(string expression)
+ {
+ var outcome = Parsing.Parse(expression);
+ return outcome.Entity is null ? "" : outcome.Entity.Evaled.Stringize();
+ }
+
+ private static readonly (string Name, Func Holds)[] Identities =
+ [
+ ("Euler's identity is exactly zero", () => Value("e^(i*pi) + 1") == "0"),
+ ("Machin: 4*atan(1/5) - atan(1/239) = pi/4",
+ () => IsZero("4*arctan(1/5) - arctan(1/239) - pi/4")),
+ ("golden ratio = 2*cos(pi/5)", () => IsZero("(1+sqrt(5))/2 - 2*cos(pi/5)")),
+ ("taxicab 1729 two ways", () => IsZero("(1^3 + 12^3) - (9^3 + 10^3)")),
+ ("42 as a sum of three cubes",
+ () => Value("(-80538738812075974)^3 + 80435758145817515^3 + 12602123297335631^3") == "42"),
+ ("Simpsons' Fermat near-miss is NOT equal",
+ () => Value("3987^12 + 4365^12 - 4472^12") != "0"),
+ ("six by nine is 42 in base 13", () => MathS.ToBaseN(54, 13) == "42"),
+ ("42 is 101010 in binary", () => MathS.ToBaseN(42, 2) == "101010"),
+ ("42 is the 5th Catalan number", () => Value("10! / (6! * 5!)") == "42"),
+ ("Pythagoras: sin^2 + cos^2 = 1", () => IsZero("sin(x)^2 + cos(x)^2 - 1")),
+ ("22/7 - pi is the integral of x^4*(1-x)^4/(1+x^2) over [0, 1]", () =>
+ {
+ // Kept as an identity so the proof the curiosities resource walks through cannot
+ // silently stop working.
+ var outcome = Parsing.Parse("x^4*(1-x)^4/(1+x^2)");
+ if (outcome.Entity is null) return false;
+ var x = MathS.Var("x");
+ var antiderivative = outcome.Entity.Integrate(x);
+ var value = antiderivative.Substitute(x, 1) - antiderivative.Substitute(x, 0);
+ return Numeric.IsZero((value - (MathS.FromString("22/7") - MathS.pi)).Simplify());
+ }),
+ ("d/dx integral of x*ln(x) returns the integrand", () =>
+ {
+ var outcome = Parsing.Parse("x*ln(x)");
+ if (outcome.Entity is null) return false;
+ var x = MathS.Var("x");
+ var back = outcome.Entity.Integrate(x).Simplify().Differentiate(x);
+ return Numeric.Equal(back, outcome.Entity) == true;
+ }),
+ ];
+
+ private static readonly (string Name, Func StillBroken, string Documented)[] Defects =
+ [
+ ("Simplify(sqrt(x^2))", () => Value("sqrt(x^2)") != "abs(x)",
+ "left as written rather than reduced to abs(x)"),
+ ("MathS.Equations does not solve a system written as equalities", () =>
+ {
+ // x + y = 3 and x - y = 1 meet at (2, 1), which the same system written in '= 0'
+ // form is solved to. A check for an exception alone would read a system that comes
+ // back with no solution as fixed. https://github.com/asc-community/AngouriMath/issues/1673
+ try
+ {
+ var solutions = MathS.Equations("x + y = 3".ToEntity(), "x - y = 1".ToEntity()).Solve("x", "y");
+ return solutions is null || solutions.RowCount != 1
+ || solutions[0, 0].Evaled != 2 || solutions[0, 1].Evaled != 1;
+ }
+ catch (Exception)
+ {
+ return true;
+ }
+ }, "x + y = 3, x - y = 1 has no solution when written as equalities; each equation must be given in '= 0' form"),
+ ("DefiniteIntegral is a first-order rule", () =>
+ {
+ // Halving the step count roughly doubles the error iff the rule is first order.
+ // https://github.com/asc-community/AngouriMath/issues/1675
+ // Numeric.TryDefiniteIntegral's step counts, its error estimate and the caveat
+ // am_integrate prints all assume that; a rule of higher order would make the
+ // reported error bound far too pessimistic and the wording wrong.
+ var e = "e^(x^2)".ToEntity();
+ var v = MathS.Var("x");
+ var coarse = e.DefiniteIntegral(v, 0, 1, 500).RealPart.EDecimal.ToDouble();
+ var fine = e.DefiniteIntegral(v, 0, 1, 1000).RealPart.EDecimal.ToDouble();
+ var finer = e.DefiniteIntegral(v, 0, 1, 2000).RealPart.EDecimal.ToDouble();
+ var ratio = Math.Abs(fine - coarse) / Math.Abs(finer - fine);
+ return ratio is > 1.7 and < 2.3;
+ }, "error halves per doubling, so ~4 digits at 4000 steps; Simpson would give more"),
+ ];
+
+ /// Runs every check, writes what it found, and returns the exit code: 1 when an identity fails.
+ public static int Run(TextWriter output) => Check(output).Failed > 0 ? 1 : 0;
+
+ /// Runs every check and writes what it found.
+ /// How many identities failed, and how many documented defects no longer reproduce.
+ internal static (int Failed, int Drifted) Check(TextWriter output)
+ {
+ output.WriteLine("amcli mcp self-test");
+ output.WriteLine();
+ output.WriteLine("Identities (these must hold):");
+
+ var failed = 0;
+ foreach (var (name, holds) in Identities)
+ {
+ // Through Guard rather than directly: a check that overflowed the stack would take
+ // the self-test down with it, and a diagnostic that dies on what it is diagnosing
+ // reports nothing.
+ var outcome = Guard.Run(holds, timeoutMs: 30_000);
+
+ if (!outcome.Ok)
+ {
+ output.WriteLine($" FAIL {name} — {outcome.Status}: {outcome.Error}");
+ failed++;
+ continue;
+ }
+
+ output.WriteLine($" {(outcome.Value ? "ok " : "FAIL")} {name}");
+ if (!outcome.Value) failed++;
+ }
+
+ output.WriteLine();
+ output.WriteLine("Documented defects (a fix here means the documents need updating):");
+
+ var drifted = 0;
+ foreach (var (name, stillBroken, documented) in Defects)
+ {
+ var outcome = Guard.Run(stillBroken, timeoutMs: 30_000);
+ if (!outcome.Ok)
+ {
+ output.WriteLine($" ? {name} — could not check ({outcome.Status})");
+ continue;
+ }
+
+ var broken = outcome.Value;
+ if (broken)
+ {
+ output.WriteLine($" still {name}: {documented}");
+ }
+ else
+ {
+ output.WriteLine($" FIXED {name} — documented as: {documented}");
+ output.WriteLine($" Update the resources and Sources/MCP/README.md, and remove this check.");
+ drifted++;
+ }
+ }
+
+ output.WriteLine();
+ if (failed > 0)
+ output.WriteLine($"{failed} identity check(s) FAILED — this install is not trustworthy.");
+ else
+ output.WriteLine($"All {Identities.Length} identities hold.");
+
+ if (drifted > 0)
+ output.WriteLine($"{drifted} documented defect(s) no longer reproduce. The documents have drifted.");
+
+ return (failed, drifted);
+ }
+}
diff --git a/Sources/MCP/AngouriMath.Mcp/Server.cs b/Sources/MCP/AngouriMath.Mcp/Server.cs
new file mode 100644
index 000000000..0566abc1a
--- /dev/null
+++ b/Sources/MCP/AngouriMath.Mcp/Server.cs
@@ -0,0 +1,261 @@
+//
+// Copyright (c) 2019-2026 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using System.Reflection;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace AngouriMath.Mcp;
+
+///
+/// The MCP server over stdio: newline-delimited JSON-RPC 2.0 read from one stream and written to
+/// another, which is what amcli mcp serves on. Nothing but protocol traffic may go to the
+/// output -- diagnostics belong on the error stream, or they corrupt the protocol.
+///
+///
+/// Requests are handled strictly one at a time. That was once a correctness requirement:
+/// MathS.Settings kept its values in thread-static fields, so two concurrent calls with
+/// different parse settings interfered. They are an AsyncLocal now and a scope follows the
+/// call, so this is no longer load-bearing -- a stdio client sends one request at a time anyway.
+/// Making the loop concurrent is a change to measure on its own.
+///
+public static class Server
+{
+ /// The version this server reports to a client, which is the library's: the two ship together.
+ public static string Version =>
+ typeof(Entity).Assembly.GetCustomAttribute()?.InformationalVersion.Split('+')[0]
+ ?? "unknown";
+
+ // A tool's answer is JSON inside a text block, and the model reads that text as written, so it
+ // is not escaped for HTML: `+` stays `+` and `α` stays `α` rather than `\u002B` and `\u03B1`.
+ // The message around it keeps the default escaping, which a client decodes.
+ private static readonly JsonSerializerOptions ToolTextOptions = new()
+ {
+ WriteIndented = false,
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ };
+
+ ///
+ /// Serves until ends, or runs the self-test or prints the usage, as
+ /// ask; returns the exit code.
+ ///
+ public static int Run(IReadOnlyList arguments, TextReader input, TextWriter output, TextWriter error)
+ {
+ // `--selftest` answers "does this install work, and does the library still behave the way
+ // the docs claim?" It exits rather than serving, so it never interferes with the protocol.
+ if (arguments.Contains("--selftest"))
+ return SelfTest.Run(output);
+
+ if (arguments.Contains("--help") || arguments.Contains("-h"))
+ {
+ output.WriteLine("amcli mcp -- exact symbolic algebra over MCP (stdio JSON-RPC).");
+ output.WriteLine();
+ output.WriteLine(" amcli mcp serve on stdin/stdout");
+ output.WriteLine(" amcli mcp --selftest verify the install and check the docs for drift");
+ output.WriteLine(" amcli mcp --help this message");
+ return 0;
+ }
+
+ var serializerOptions = new JsonSerializerOptions { WriteIndented = false };
+
+ void Send(JsonObject message)
+ {
+ output.WriteLine(message.ToJsonString(serializerOptions));
+ output.Flush();
+ }
+
+ string? line;
+ while ((line = input.ReadLine()) is not null)
+ {
+ if (string.IsNullOrWhiteSpace(line))
+ continue;
+
+ JsonObject? request;
+ try
+ {
+ request = JsonNode.Parse(line) as JsonObject;
+ }
+ catch (JsonException e)
+ {
+ Send(Error(null, -32700, $"parse error: {e.Message}"));
+ continue;
+ }
+
+ if (request is null)
+ continue;
+
+ var id = request.TryGetPropertyValue("id", out var idNode) ? idNode?.DeepClone() : null;
+ var method = request.TryGetPropertyValue("method", out var m) ? m?.GetValue() : null;
+ var parameters = request.TryGetPropertyValue("params", out var p) ? p as JsonObject : null;
+
+ if (method is null)
+ {
+ if (id is not null)
+ Send(Error(id, -32600, "missing 'method'"));
+ continue;
+ }
+
+ // Notifications carry no id and must never be answered.
+ var isNotification = id is null;
+
+ try
+ {
+ var result = Dispatch(method, parameters, error);
+ if (result is null)
+ {
+ if (!isNotification)
+ Send(Error(id, -32601, $"unknown method '{method}'"));
+ continue;
+ }
+ if (!isNotification)
+ Send(Ok(id, result));
+ }
+ catch (Exception e)
+ {
+ error.WriteLine($"[amcli mcp] {method} failed: {e}");
+ if (!isNotification)
+ Send(Error(id, -32603, $"{e.GetType().Name}: {e.Message}"));
+ }
+ }
+
+ // The input ended: the host has gone away, which is a normal shutdown.
+ return 0;
+ }
+
+ private static JsonObject? Dispatch(string method, JsonObject? parameters, TextWriter error)
+ {
+ switch (method)
+ {
+ case "initialize":
+ return new JsonObject
+ {
+ // Pinned rather than echoed: this server implements exactly this revision.
+ ["protocolVersion"] = "2024-11-05",
+ ["capabilities"] = new JsonObject
+ {
+ ["tools"] = new JsonObject(),
+ ["resources"] = new JsonObject(),
+ ["prompts"] = new JsonObject(),
+ },
+ ["serverInfo"] = new JsonObject
+ {
+ ["name"] = "angourimath",
+ ["version"] = Version,
+ },
+ ["instructions"] =
+ "Exact symbolic algebra: simplify, solve, differentiate, integrate, " +
+ "limits, truth tables. Prefer these tools over doing algebra yourself — " +
+ "every answer is machine-checked and integrals are verified by " +
+ "differentiating them back. Always read the 'parsed' field to confirm " +
+ "the expression was understood as intended, and treat a 'declined' or " +
+ "'unchanged' status as 'no answer', never as the answer. Read the " +
+ "angourimath://syntax resource before composing unusual expressions.",
+ };
+
+ // Notifications. Acknowledged by returning an empty object; the caller suppresses
+ // the response because there is no id.
+ case "notifications/initialized":
+ case "notifications/cancelled":
+ return new JsonObject();
+
+ case "ping":
+ return new JsonObject();
+
+ case "tools/list":
+ return new JsonObject { ["tools"] = Tools.List() };
+
+ case "tools/call":
+ {
+ var name = parameters?["name"]?.GetValue();
+ if (name is null)
+ return ToolText("{\"status\":\"failed\",\"error\":\"missing tool name\"}", true);
+
+ var arguments = parameters?["arguments"] as JsonObject ?? new JsonObject();
+
+ JsonObject payload;
+ try
+ {
+ payload = Tools.Call(name, arguments);
+ }
+ catch (Exception e)
+ {
+ // A thrown exception here is a defect in this server, not bad user input;
+ // report it as a tool error rather than killing the connection.
+ error.WriteLine($"[amcli mcp] tool {name} threw: {e}");
+ payload = new JsonObject
+ {
+ ["status"] = "failed",
+ ["error"] = $"{e.GetType().Name}: {e.Message}",
+ };
+ }
+
+ var isError = payload["status"]?.GetValue() is "failed";
+ return ToolText(payload.ToJsonString(ToolTextOptions), isError);
+ }
+
+ case "prompts/list":
+ return new JsonObject { ["prompts"] = Prompts.List() };
+
+ case "prompts/get":
+ {
+ var promptName = parameters?["name"]?.GetValue()
+ ?? throw new InvalidOperationException("prompts/get requires a name");
+ return Prompts.Get(promptName, parameters?["arguments"] as JsonObject)
+ ?? throw new InvalidOperationException($"no such prompt: {promptName}");
+ }
+
+ case "resources/list":
+ return new JsonObject { ["resources"] = Resources.List() };
+
+ case "resources/read":
+ {
+ var uri = parameters?["uri"]?.GetValue();
+ var text = (uri is null ? null : Resources.Read(uri))
+ ?? throw new InvalidOperationException($"no such resource: {uri}");
+ return new JsonObject
+ {
+ ["contents"] = new JsonArray
+ {
+ new JsonObject
+ {
+ ["uri"] = uri,
+ ["mimeType"] = "text/markdown",
+ ["text"] = text,
+ },
+ },
+ };
+ }
+
+ default:
+ return null;
+ }
+ }
+
+ private static JsonObject ToolText(string text, bool isError) => new()
+ {
+ ["content"] = new JsonArray
+ {
+ new JsonObject { ["type"] = "text", ["text"] = text },
+ },
+ ["isError"] = isError,
+ };
+
+ private static JsonObject Ok(JsonNode? id, JsonObject result) => new()
+ {
+ ["jsonrpc"] = "2.0",
+ ["id"] = id,
+ ["result"] = result,
+ };
+
+ private static JsonObject Error(JsonNode? id, int code, string message) => new()
+ {
+ ["jsonrpc"] = "2.0",
+ ["id"] = id,
+ ["error"] = new JsonObject { ["code"] = code, ["message"] = message },
+ };
+}
diff --git a/Sources/MCP/AngouriMath.Mcp/Tools.cs b/Sources/MCP/AngouriMath.Mcp/Tools.cs
new file mode 100644
index 000000000..b260d487a
--- /dev/null
+++ b/Sources/MCP/AngouriMath.Mcp/Tools.cs
@@ -0,0 +1,2043 @@
+//
+// Copyright (c) 2019-2026 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using System.Text.Json.Nodes;
+using AngouriMath;
+using AngouriMath.Core;
+using AngouriMath.Extensions;
+using static AngouriMath.Entity;
+
+namespace AngouriMath.Mcp;
+
+internal static class Tools
+{
+ // ---------------------------------------------------------------- schemas
+
+ private static JsonObject Str(string desc) =>
+ new() { ["type"] = "string", ["description"] = desc };
+
+ private static JsonObject Schema(JsonObject props, params string[] required)
+ {
+ var req = new JsonArray();
+ foreach (var r in required) req.Add(r);
+ return new JsonObject
+ {
+ ["type"] = "object",
+ ["properties"] = props,
+ ["required"] = req,
+ };
+ }
+
+ private static JsonObject Tool(string name, string description, JsonObject schema) => new()
+ {
+ ["name"] = name,
+ ["description"] = description,
+ ["inputSchema"] = schema,
+ // Pure computation: no side effects, no network, no filesystem. Declaring this lets
+ // clients auto-approve instead of prompting on every call — and a math tool that
+ // costs a permission click per invocation simply will not get used.
+ ["annotations"] = new JsonObject
+ {
+ ["readOnlyHint"] = true,
+ ["destructiveHint"] = false,
+ ["idempotentHint"] = true,
+ ["openWorldHint"] = false,
+ },
+ };
+
+ private const string CallEvenIfConfident =
+ " Call this even when you believe you can do the algebra unaided — unaided symbolic " +
+ "manipulation is unreliable for exactly this class of problem, and the result here is " +
+ "machine-checked.";
+
+ public static JsonArray List() =>
+ [
+ Tool("am_parse",
+ "Parse an expression and show exactly how AngouriMath understood it. Use this " +
+ "first whenever an expression contains identifiers with digits (x2, v1) or " +
+ "function-looking names, because both are silently misread. Returns the canonical " +
+ "form, LaTeX, free variables, and warnings.",
+ Schema(new JsonObject
+ {
+ ["expression"] = Str("Expression in AngouriMath syntax, e.g. 'x^2 + 3*x - 1'."),
+ ["strict"] = new JsonObject
+ {
+ ["type"] = "boolean",
+ ["description"] = "Reject implicit multiplication instead of guessing. Default false.",
+ },
+ }, "expression")),
+
+ Tool("am_simplify",
+ "Simplify an expression exactly." + CallEvenIfConfident +
+ " Set alternatives=true to get several candidate forms ranked by complexity, " +
+ "which is useful because the default form is not always the tidiest.",
+ Schema(new JsonObject
+ {
+ ["expression"] = Str("Expression to simplify."),
+ ["alternatives"] = new JsonObject
+ {
+ ["type"] = "boolean",
+ ["description"] = "Also return alternative simplified forms. Default false.",
+ },
+ }, "expression")),
+
+ Tool("am_solve",
+ "Solve an equation, inequality, or a set of simultaneous constraints for one " +
+ "variable. Constraints are combined, so you can narrow a solution incrementally " +
+ "(e.g. ['x^2 = 4', 'x > 0'] gives 2)." + CallEvenIfConfident,
+ Schema(new JsonObject
+ {
+ ["constraints"] = new JsonObject
+ {
+ ["type"] = "array",
+ ["items"] = new JsonObject { ["type"] = "string" },
+ ["description"] = "One or more statements, e.g. ['x^2 - 1 = 0'] or ['x^2=4','x>0'].",
+ },
+ ["variable"] = Str("Variable to solve for, e.g. 'x'."),
+ }, "constraints", "variable")),
+
+ Tool("am_differentiate",
+ "Differentiate an expression with respect to a variable." + CallEvenIfConfident,
+ Schema(new JsonObject
+ {
+ ["expression"] = Str("Expression to differentiate."),
+ ["variable"] = Str("Variable to differentiate by."),
+ ["order"] = new JsonObject
+ {
+ ["type"] = "integer",
+ ["description"] = "Order of the derivative. Default 1.",
+ },
+ }, "expression", "variable")),
+
+ Tool("am_integrate",
+ "Integrate an expression symbolically. The answer is ALWAYS verified by " +
+ "differentiating it back and comparing numerically; check the 'verified' field. " +
+ "If the library has no rule for the integrand the status is 'declined', which is " +
+ "an honest answer, not a failure — and when 'from' and 'to' are given, a numeric " +
+ "value for that one interval still comes back in 'numeric_definite_value', " +
+ "rounded to the digits it is actually good for." + CallEvenIfConfident,
+ Schema(new JsonObject
+ {
+ ["expression"] = Str("Integrand."),
+ ["variable"] = Str("Variable of integration."),
+ ["from"] = Str("Lower limit. Give both 'from' and 'to' for a definite integral."),
+ ["to"] = Str("Upper limit."),
+ }, "expression", "variable")),
+
+ Tool("am_limit",
+ "Compute a limit, optionally one-sided." + CallEvenIfConfident,
+ Schema(new JsonObject
+ {
+ ["expression"] = Str("Expression."),
+ ["variable"] = Str("Variable approaching the destination."),
+ ["to"] = Str("Destination, e.g. '0', '+oo', '-oo'."),
+ ["side"] = new JsonObject
+ {
+ ["type"] = "string",
+ ["enum"] = new JsonArray { "both", "left", "right" },
+ ["description"] = "Approach direction. Default 'both'.",
+ },
+ }, "expression", "variable", "to")),
+
+ Tool("am_evaluate",
+ "Evaluate an expression numerically, optionally substituting values first. " +
+ "Returns exact form and a decimal approximation.",
+ Schema(new JsonObject
+ {
+ ["expression"] = Str("Expression to evaluate."),
+ ["substitutions"] = new JsonObject
+ {
+ ["type"] = "object",
+ ["additionalProperties"] = new JsonObject { ["type"] = "string" },
+ ["description"] = "Optional map of variable -> value, e.g. {\"x\": \"2\"}.",
+ },
+ ["digits"] = new JsonObject
+ {
+ ["type"] = "integer",
+ ["description"] = "Significant digits for the numeric result, 1-1000. " +
+ "Omit for a normal double. Raise it when the question turns on the " +
+ "tail — e^(pi*sqrt(163)) only looks like an integer until digit 18.",
+ },
+ }, "expression")),
+
+ Tool("am_verify_equal",
+ "Check whether two expressions are mathematically equal. Use this to check your " +
+ "OWN algebra: give it your hand-derived form and the original, and it will tell " +
+ "you if you changed the meaning. Tries exact simplification first, then falls " +
+ "back to numeric sampling.",
+ Schema(new JsonObject
+ {
+ ["left"] = Str("First expression."),
+ ["right"] = Str("Second expression."),
+ }, "left", "right")),
+
+ Tool("am_truth_table",
+ "Build a truth table for a boolean expression, and list the assignments that " +
+ "satisfy it. Useful for reasoning about branch conditions and predicates. " +
+ "Operators: 'and', 'or', 'not', 'xor', 'implies'.",
+ Schema(new JsonObject
+ {
+ ["expression"] = Str("Boolean expression, e.g. 'a and (b or not c)'."),
+ ["variables"] = new JsonObject
+ {
+ ["type"] = "array",
+ ["items"] = new JsonObject { ["type"] = "string" },
+ ["description"] = "Variable order. Inferred from the expression if omitted.",
+ },
+ }, "expression")),
+
+ Tool("am_solve_system",
+ "Solve a system of simultaneous equations for several variables. Returns one row " +
+ "per solution. Note: nonlinear systems may return no solution even when one " +
+ "exists (a known library limitation).",
+ Schema(new JsonObject
+ {
+ ["equations"] = new JsonObject
+ {
+ ["type"] = "array",
+ ["items"] = new JsonObject { ["type"] = "string" },
+ ["description"] = "Equations, e.g. ['x + y = 3', 'x - y = 1'].",
+ },
+ ["variables"] = new JsonObject
+ {
+ ["type"] = "array",
+ ["items"] = new JsonObject { ["type"] = "string" },
+ ["description"] = "Variables to solve for, e.g. ['x','y'].",
+ },
+ }, "equations", "variables")),
+
+ Tool("am_check_steps",
+ "Check a chain of working step by step and report WHICH step broke. Use this to " +
+ "present a derivation: write out your steps, have them checked, then explain " +
+ "them. AngouriMath cannot generate a derivation — but you can, and this makes " +
+ "yours trustworthy. Each consecutive pair must be mathematically equal.",
+ Schema(new JsonObject
+ {
+ ["steps"] = new JsonObject
+ {
+ ["type"] = "array",
+ ["items"] = new JsonObject { ["type"] = "string" },
+ ["description"] = "Successive forms of the SAME expression, in order, " +
+ "e.g. ['(x+1)^2 - 1', 'x^2 + 2*x + 1 - 1', 'x^2 + 2*x'].",
+ },
+ }, "steps")),
+
+ Tool("am_domain_check",
+ "Find what to watch out for in an expression: the domain conditions AngouriMath " +
+ "derived, the structural hazards (division, logs, fractional powers, inverse " +
+ "trig), and the sample points where it stops being a real number. Use this " +
+ "before trusting a result as a physical quantity — a value can be perfectly " +
+ "correct and still be meaningless as a length, a time or a resistance.",
+ Schema(new JsonObject
+ {
+ ["expression"] = Str("Expression to inspect."),
+ }, "expression")),
+
+ Tool("am_represent",
+ "How a number is ENCODED, rather than what it equals. Bases 2-36, including " +
+ "fractions and repeating expansions. Q-format fixed point, reporting the " +
+ "quantisation error and whether the value saturated — the question you actually " +
+ "have when putting a coefficient into a processor. The bits of an IEEE 754 float " +
+ "or double together with the exact decimal it holds, which is how you settle why " +
+ "0.1 is not 0.1. And polar form, kept symbolic, so 1+i gives sqrt(2) and pi/4 " +
+ "rather than 1.414 and 0.785.",
+ Schema(new JsonObject
+ {
+ ["operation"] = new JsonObject
+ {
+ ["type"] = "string",
+ ["enum"] = new JsonArray
+ {
+ "base", "fixed_point", "from_fixed_point", "ieee754",
+ "polar", "rectangular",
+ },
+ ["description"] = "Which representation to produce.",
+ },
+ ["value"] = Str("The number as an expression. For 'from_fixed_point', the " +
+ "raw stored integer. Unused by 'rectangular'."),
+ ["from_base"] = new JsonObject
+ {
+ ["type"] = "integer",
+ ["description"] = "base: base of the input, 2-36. Default 10.",
+ },
+ ["to_base"] = new JsonObject
+ {
+ ["type"] = "integer",
+ ["description"] = "base: base to convert to, 2-36.",
+ },
+ ["total_bits"] = new JsonObject
+ {
+ ["type"] = "integer",
+ ["description"] = "fixed_point: machine word width, e.g. 16 or 32. Default 16.",
+ },
+ ["fraction_bits"] = new JsonObject
+ {
+ ["type"] = "integer",
+ ["description"] = "fixed_point / from_fixed_point: bits after the binary " +
+ "point. Q15 in a 16-bit word means 15.",
+ },
+ ["signed"] = new JsonObject
+ {
+ ["type"] = "boolean",
+ ["description"] = "fixed_point: signed word. Default true.",
+ },
+ ["precision"] = new JsonObject
+ {
+ ["type"] = "string",
+ ["enum"] = new JsonArray { "double", "single" },
+ ["description"] = "ieee754: which format. Default double.",
+ },
+ ["magnitude"] = Str("rectangular: magnitude."),
+ ["phase"] = Str("rectangular: phase in radians."),
+ }, "operation")),
+
+ Tool("am_matrix",
+ "Exact symbolic linear algebra. Entries are expressions, so they may contain " +
+ "variables — the determinant of a matrix of symbols comes back as a formula, not " +
+ "a number. `tensor_product` builds multi-qubit operators from single-qubit ones, " +
+ "which is what makes quantum circuit algebra work: CNOT * (H (x) I) * |00> gives " +
+ "the Bell state as exactly 1/sqrt(2), not 0.7071.",
+ Schema(new JsonObject
+ {
+ ["operation"] = new JsonObject
+ {
+ ["type"] = "string",
+ ["enum"] = new JsonArray
+ {
+ "determinant", "inverse", "transpose", "rank", "rref", "trace",
+ "multiply", "add", "subtract", "tensor_product", "power",
+ },
+ ["description"] = "What to compute.",
+ },
+ ["matrix"] = new JsonObject
+ {
+ ["type"] = "array",
+ ["items"] = new JsonObject
+ {
+ ["type"] = "array",
+ ["items"] = new JsonObject { ["type"] = "string" },
+ },
+ ["description"] = "Rows of expression strings, e.g. [[\"1\",\"x\"],[\"0\",\"1\"]]. " +
+ "A column vector is [[\"1\"],[\"0\"]].",
+ },
+ ["matrix_b"] = new JsonObject
+ {
+ ["type"] = "array",
+ ["items"] = new JsonObject
+ {
+ ["type"] = "array",
+ ["items"] = new JsonObject { ["type"] = "string" },
+ },
+ ["description"] = "Second operand, for multiply / add / subtract / tensor_product.",
+ },
+ ["exponent"] = new JsonObject
+ {
+ ["type"] = "integer",
+ ["description"] = "For 'power'. Non-negative.",
+ },
+ }, "operation", "matrix")),
+
+ Tool("am_eigenvalues",
+ "Eigenvalues of a square matrix, exactly and symbolically — entries may contain " +
+ "variables, so a Hamiltonian [[0,J],[J,0]] returns its energy levels in terms of J. Computed " +
+ "from the characteristic polynomial det(A - lambda*I). Expect a decline above 4x4 " +
+ "with symbolic entries: quintics have no closed form (Abel-Ruffini), which is a " +
+ "fact about mathematics rather than a limitation of this server.",
+ Schema(new JsonObject
+ {
+ ["matrix"] = new JsonObject
+ {
+ ["type"] = "array",
+ ["items"] = new JsonObject
+ {
+ ["type"] = "array",
+ ["items"] = new JsonObject { ["type"] = "string" },
+ },
+ ["description"] = "Square matrix as rows of expression strings.",
+ },
+ }, "matrix")),
+
+ Tool("am_compare_numeric",
+ "Measure how far apart two expressions are across an interval: worst absolute " +
+ "error, worst relative error, RMS, and WHERE the worst point is. Use this for " +
+ "'is this approximation good enough over the operating range' — a different " +
+ "question from am_verify_equal, which asks whether two forms are the same " +
+ "function. An approximation is never equal; the question is whether it is close " +
+ "enough, and where it degrades.",
+ Schema(new JsonObject
+ {
+ ["reference"] = Str("The exact expression, used as the baseline."),
+ ["approximation"] = Str("The expression being checked against it."),
+ ["variable"] = Str("Variable to sweep."),
+ ["from"] = new JsonObject
+ {
+ ["type"] = "number",
+ ["description"] = "Start of the interval.",
+ },
+ ["to"] = new JsonObject
+ {
+ ["type"] = "number",
+ ["description"] = "End of the interval.",
+ },
+ ["samples"] = new JsonObject
+ {
+ ["type"] = "integer",
+ ["description"] = "Evenly spaced sample count, 2-10000. Default 200.",
+ },
+ }, "reference", "approximation", "variable", "from", "to")),
+
+ Tool("am_substitute",
+ "Replace variables with expressions and return the result WITHOUT evaluating or " +
+ "simplifying it. Use this when you want to see the shape of a substitution — " +
+ "putting a model into a formula, specialising a general result — rather than a " +
+ "number. am_evaluate always simplifies; this does not.",
+ Schema(new JsonObject
+ {
+ ["expression"] = Str("Expression to substitute into."),
+ ["substitutions"] = new JsonObject
+ {
+ ["type"] = "object",
+ ["additionalProperties"] = new JsonObject { ["type"] = "string" },
+ ["description"] = "Map of variable -> replacement expression, " +
+ "e.g. {\"x\": \"a + b\"}.",
+ },
+ ["simplify"] = new JsonObject
+ {
+ ["type"] = "boolean",
+ ["description"] = "Simplify afterwards as well. Default false.",
+ },
+ }, "expression", "substitutions")),
+
+ Tool("am_expand",
+ "Expand brackets and powers into a sum of terms. The opposite of am_factor.",
+ Schema(new JsonObject
+ {
+ ["expression"] = Str("Expression to expand, e.g. '(x+1)^3'."),
+ }, "expression")),
+
+ Tool("am_factor",
+ "Factorise an expression into a product. The opposite of am_expand. Multivariate " +
+ "factoring is limited, so a result identical to the input means no progress.",
+ Schema(new JsonObject
+ {
+ ["expression"] = Str("Expression to factorise, e.g. 'x^2 - 1'."),
+ }, "expression")),
+
+ Tool("am_series",
+ "Taylor or Maclaurin series expansion to a given degree. Use it to linearise a " +
+ "model around an operating point, to justify a small-angle approximation, or to " +
+ "decide how many terms a fixed-point implementation actually needs.",
+ Schema(new JsonObject
+ {
+ ["expression"] = Str("Expression to expand, e.g. 'sin(x)'."),
+ ["variable"] = Str("Variable to expand in."),
+ ["degree"] = new JsonObject
+ {
+ ["type"] = "integer",
+ ["description"] = "Highest power to include. Keep it modest; cost grows quickly.",
+ },
+ ["around"] = Str("Point to expand about. Omit for 0 (a Maclaurin series)."),
+ }, "expression", "variable", "degree")),
+
+ Tool("am_number_theory",
+ "Integer facts: prime factorisation, Euler's totient, greatest common divisor, " +
+ "divisor count, primality. Exact for large integers.",
+ Schema(new JsonObject
+ {
+ ["operation"] = new JsonObject
+ {
+ ["type"] = "string",
+ ["enum"] = new JsonArray
+ {
+ "factorize", "totient", "gcd", "count_divisors", "is_prime",
+ },
+ ["description"] = "Which fact to compute.",
+ },
+ ["value"] = Str("The integer, as an expression, e.g. '5040'."),
+ ["value_b"] = Str("Second integer, for 'gcd'."),
+ }, "operation", "value")),
+
+ Tool("am_classify",
+ "Guess which field of science or mathematics a formula comes from, by reading " +
+ "its symbols and structure. Inference from naming convention only — 'c' is the " +
+ "speed of light in relativity and a concentration in chemistry — so treat the " +
+ "result as a hint, and say it is a guess when you repeat it.",
+ Schema(new JsonObject
+ {
+ ["expression"] = Str("Formula to classify, e.g. 'G*M*m/r^2'."),
+ }, "expression")),
+
+ Tool("am_to_sympy",
+ "Emit a runnable SymPy program for an expression. Use this to hand the problem to " +
+ "Python, or to cross-check a result against a second computer algebra system.",
+ Schema(new JsonObject { ["expression"] = Str("Expression to translate.") },
+ "expression")),
+ ];
+
+ // ---------------------------------------------------------------- dispatch
+
+ public static JsonObject Call(string name, JsonObject args) => name switch
+ {
+ "am_parse" => Parse(args),
+ "am_simplify" => Simplify(args),
+ "am_solve" => Solve(args),
+ "am_differentiate" => Differentiate(args),
+ "am_integrate" => Integrate(args),
+ "am_limit" => Limit(args),
+ "am_evaluate" => Evaluate(args),
+ "am_verify_equal" => VerifyEqual(args),
+ "am_truth_table" => TruthTable(args),
+ "am_solve_system" => SolveSystem(args),
+ "am_check_steps" => CheckSteps(args),
+ "am_domain_check" => DomainCheck(args),
+ "am_represent" => Represent(args),
+ "am_matrix" => MatrixOp(args),
+ "am_eigenvalues" => Eigenvalues(args),
+ "am_compare_numeric" => CompareNumeric(args),
+ "am_substitute" => Substitute(args),
+ "am_expand" => Rewrite(args, expand: true),
+ "am_factor" => Rewrite(args, expand: false),
+ "am_series" => Series(args),
+ "am_number_theory" => NumberTheory(args),
+ "am_classify" => ClassifyFormula(args),
+ "am_to_sympy" => ToSympy(args),
+ _ => Fail($"unknown tool '{name}'"),
+ };
+
+ // ---------------------------------------------------------------- helpers
+
+ private static string? S(JsonObject o, string key) =>
+ o.TryGetPropertyValue(key, out var v) ? v?.GetValue() : null;
+
+ private static bool B(JsonObject o, string key, bool fallback = false) =>
+ o.TryGetPropertyValue(key, out var v) && v is not null ? v.GetValue() : fallback;
+
+ private static List A(JsonObject o, string key)
+ {
+ var list = new List();
+ if (o.TryGetPropertyValue(key, out var v) && v is JsonArray arr)
+ foreach (var item in arr)
+ if (item is not null) list.Add(item.GetValue());
+ return list;
+ }
+
+ private static JsonObject Fail(string error) =>
+ new() { ["status"] = "failed", ["error"] = error };
+
+ private static JsonArray Warn(List warnings)
+ {
+ var arr = new JsonArray();
+ foreach (var w in warnings) arr.Add(w);
+ return arr;
+ }
+
+ /// Parse one input, or produce the error object.
+ private static bool TryParse(string? src, string label, bool strict,
+ out Entity entity, out List warnings, out JsonObject? error)
+ {
+ entity = 0; warnings = []; error = null;
+ if (string.IsNullOrWhiteSpace(src))
+ {
+ error = Fail($"'{label}' is required");
+ return false;
+ }
+
+ var outcome = Parsing.Parse(src, strict);
+ if (outcome.Entity is null)
+ {
+ error = Fail(outcome.Error ?? "parse failed");
+ return false;
+ }
+
+ entity = outcome.Entity;
+ warnings = outcome.Warnings;
+ return true;
+ }
+
+ ///
+ /// Build the standard response. The order matters: decline detection runs on the RAW
+ /// stringized result before anything simplifies it, because an unevaluated limit(...)
+ /// simplifies to NaN and would otherwise be reported as a wrong answer.
+ ///
+ private static JsonObject Respond(Entity input, Entity result, List warnings)
+ {
+ var raw = result.Stringize();
+ var status =
+ Guard.IsDeclined(raw) ? "declined"
+ : raw == input.Stringize() ? "unchanged"
+ : Guard.LooksLikeNaN(raw) ? "suspect"
+ : "solved";
+
+ var response = new JsonObject
+ {
+ ["status"] = status,
+ ["parsed"] = input.Stringize(),
+ ["result"] = raw,
+ ["latex"] = result.Latexize(),
+ };
+
+ if (status == "declined")
+ response["note"] = "AngouriMath returned the expression unevaluated: it has no " +
+ "rule for this input. This is a decline, not a wrong answer — do not present " +
+ "the unevaluated form as a result.";
+ if (status == "unchanged")
+ response["note"] = "The result is identical to the input. The library could not " +
+ "reduce it further; treat this as 'no progress', not as a simplest form.";
+ if (status == "suspect")
+ response["note"] = "The result contains NaN, which is almost never a legitimate " +
+ "answer here. Treat it as a library failure.";
+
+ if (warnings.Count > 0) response["warnings"] = Warn(warnings);
+ return response;
+ }
+
+ private static JsonObject FromOutcome(Guard.Outcome outcome, Func ok) =>
+ outcome.Status switch
+ {
+ "ok" => ok(outcome.Value!),
+ "timeout" => new JsonObject
+ {
+ ["status"] = "timeout",
+ ["error"] = outcome.Error,
+ ["note"] = "The call exceeded its budget and was abandoned. Try a simpler " +
+ "form, or accept that this input is out of reach.",
+ },
+ // A NotSufficientlySupportedException, which the library raises where it knows
+ // it has no rule. Reported with the same word as an unevaluated integral because
+ // it means the same thing to the caller. See Guard.StatusFor.
+ "declined" => new JsonObject
+ {
+ ["status"] = "declined",
+ ["error"] = outcome.Error,
+ ["note"] = "AngouriMath declined this input: it says explicitly that it has " +
+ "no rule covering this case. That is a limit of the library, not " +
+ "a wrong answer and not a bug worth reporting — do not retry the " +
+ "same call, and do not present the message as a result.",
+ },
+ _ => Fail(outcome.Error ?? "unknown failure"),
+ };
+
+ private static Variable Var(string name) => MathS.Var(name);
+
+ // Numeric comparison, `provided`-stripping and the sample points all live in Numeric.cs.
+
+ // ---------------------------------------------------------------- tools
+
+ private static JsonObject Parse(JsonObject args)
+ {
+ if (!TryParse(S(args, "expression"), "expression", B(args, "strict"),
+ out var e, out var warnings, out var error))
+ return error!;
+
+ var vars = new JsonArray();
+ foreach (var v in e.Vars) vars.Add(v.Stringize());
+
+ var response = new JsonObject
+ {
+ ["status"] = "solved",
+ ["parsed"] = e.Stringize(),
+ ["result"] = e.Stringize(),
+ ["latex"] = e.Latexize(),
+ ["free_variables"] = vars,
+ ["node_count"] = e.Nodes.Count(),
+ };
+ if (warnings.Count > 0) response["warnings"] = Warn(warnings);
+ return response;
+ }
+
+ private static JsonObject Simplify(JsonObject args)
+ {
+ if (!TryParse(S(args, "expression"), "expression", false,
+ out var e, out var warnings, out var error))
+ return error!;
+
+ var wantAlternatives = B(args, "alternatives");
+
+ return FromOutcome(Guard.Run(() =>
+ {
+ var simplified = e.Simplify();
+ var alternates = new List();
+ if (wantAlternatives)
+ foreach (var alt in e.Alternate(4).Take(5))
+ {
+ var s = alt.Stringize();
+ if (s != simplified.Stringize() && !alternates.Contains(s))
+ alternates.Add(s);
+ }
+ return (simplified, alternates);
+ }), r =>
+ {
+ var response = Respond(e, r.simplified, warnings);
+ if (r.alternates.Count > 0)
+ {
+ var arr = new JsonArray();
+ foreach (var a in r.alternates) arr.Add(a);
+ response["alternatives"] = arr;
+ response["alternatives_note"] =
+ "Ranked by the library's complexity metric. The first result is not " +
+ "always the tidiest for a human — pick whichever form suits.";
+ }
+ return response;
+ });
+ }
+
+ private static JsonObject Solve(JsonObject args)
+ {
+ var constraints = A(args, "constraints");
+ var variable = S(args, "variable");
+ if (constraints.Count == 0) return Fail("'constraints' must contain at least one statement");
+ if (string.IsNullOrWhiteSpace(variable)) return Fail("'variable' is required");
+
+ // Combining with 'and' turns several constraints into a single boolean statement,
+ // which Solve handles natively (it accepts statements and inequalities, not just
+ // equations). Borrowed from the SharpCells Excel example.
+ var combined = string.Join(" and ", constraints);
+
+ if (!TryParse(combined, "constraints", false, out var e, out var warnings, out var error))
+ return error!;
+
+ return FromOutcome(Guard.Run(() => e.Solve(Var(variable!))), set =>
+ {
+ var response = Respond(e, set, warnings);
+ if (set is Set.FiniteSet finite)
+ {
+ var roots = new JsonArray();
+ foreach (var root in finite)
+ {
+ // Solve leaves roots in raw form: solving f = 1/(2*pi*R*C) for R yields
+ // "--1/2 * 1/pi * 1/C/f". Correct, but a double negation is exactly the
+ // output-tidiness weakness the library is known for, so tidy each root.
+ // Guarded because Simplify can be slow and must not lose the answer.
+ Entity tidy;
+ try { tidy = root.Simplify(); }
+ catch { tidy = root; }
+ roots.Add(tidy.Stringize());
+ }
+ response["solutions"] = roots;
+ response["solution_count"] = finite.Count;
+ }
+ else
+ {
+ response["note"] = "The solution set is not finite (an interval, a " +
+ "conditional set, or a periodic family). Read 'result' directly.";
+ }
+ return response;
+ });
+ }
+
+ private static JsonObject Differentiate(JsonObject args)
+ {
+ if (!TryParse(S(args, "expression"), "expression", false,
+ out var e, out var warnings, out var error))
+ return error!;
+ var variable = S(args, "variable");
+ if (string.IsNullOrWhiteSpace(variable)) return Fail("'variable' is required");
+
+ var order = args.TryGetPropertyValue("order", out var o) && o is not null
+ ? o.GetValue() : 1;
+ if (order < 1) return Fail("'order' must be at least 1");
+
+ return FromOutcome(
+ Guard.Run(() => e.Differentiate(Var(variable!), order).Simplify()),
+ d => Respond(e, d, warnings));
+ }
+
+ private static JsonObject Integrate(JsonObject args)
+ {
+ if (!TryParse(S(args, "expression"), "expression", false,
+ out var e, out var warnings, out var error))
+ return error!;
+ var variable = S(args, "variable");
+ if (string.IsNullOrWhiteSpace(variable)) return Fail("'variable' is required");
+
+ var fromText = S(args, "from");
+ var toText = S(args, "to");
+ if (string.IsNullOrWhiteSpace(fromText) != string.IsNullOrWhiteSpace(toText))
+ return Fail("give both 'from' and 'to', or neither");
+
+ Entity? lower = null, upper = null;
+ if (!string.IsNullOrWhiteSpace(fromText))
+ {
+ if (!TryParse(fromText, "from", false, out var l, out _, out var lowerError))
+ return lowerError!;
+ if (!TryParse(toText, "to", false, out var u, out _, out var upperError))
+ return upperError!;
+ lower = l;
+ upper = u;
+ }
+
+ return FromOutcome(Guard.Run(() =>
+ {
+ var v = Var(variable!);
+
+ // The numeric route, used only where the symbolic one produced nothing usable.
+ // It is never a substitute for an antiderivative: it answers one interval, to a
+ // handful of digits, and it cannot be verified by differentiating back.
+ Numeric.Quadrature? Quadrature()
+ {
+ if (lower is null || upper is null) return null;
+ if (Numeric.AsRealNumber(lower) is not { } a) return null;
+ if (Numeric.AsRealNumber(upper) is not { } b) return null;
+ if (Numeric.Strip(e).Vars.Any(other => other.Name != v.Name)) return null;
+ return Numeric.TryDefiniteIntegral(e, v, a, b);
+ }
+
+ var antiderivative = e.Integrate(v);
+
+ // Decline check on the RAW result, before Simplify — see Guard.IsDeclined.
+ if (Guard.IsDeclined(antiderivative.Stringize()))
+ return (antiderivative, (bool?)null, (Entity?)null, Quadrature());
+
+ var tidy = antiderivative.Simplify();
+
+ // Verify by differentiating back, which is the whole reason to trust the answer.
+ bool? verified;
+ try
+ {
+ verified = Numeric.Equal(tidy.Differentiate(v), e);
+ }
+ catch
+ {
+ verified = null;
+ }
+
+ // The definite value is evaluated from the SAME antiderivative that was just
+ // verified, rather than asked for separately — so the verification covers it.
+ Entity? definite = null;
+ if (lower is not null && upper is not null)
+ {
+ var bare = Numeric.Strip(tidy);
+ definite = (bare.Substitute(v, upper) - bare.Substitute(v, lower)).Simplify();
+ }
+
+ // Only where the exact route came back with nothing to show. A NaN definite
+ // value is the common case: the antiderivative exists but is undefined at a
+ // bound, and a number the caller can use beats `NaN` with a caveat attached.
+ var quadrature = definite is null || Guard.LooksLikeNaN(definite.Stringize())
+ ? Quadrature()
+ : null;
+
+ return (tidy, verified, definite, quadrature);
+ }), r =>
+ {
+ var response = Respond(e, r.Item1, warnings);
+ response["verified"] = r.Item2;
+ response["verification_method"] =
+ "differentiated the answer and compared it numerically to the integrand at " +
+ "five positive real points";
+
+ if (r.Item2 == false)
+ {
+ response["status"] = "suspect";
+ response["note"] = "The answer does NOT differentiate back to the integrand. " +
+ "Do not use it.";
+ }
+ else if (r.Item2 is null && (string?)response["status"] == "declined")
+ {
+ response["note"] = "No antiderivative was found: the library has no rule " +
+ "for this integrand. Some, such as x^x, have no closed form in any " +
+ "function the library knows, so this can be the correct outcome.";
+ }
+
+ if (r.Item3 is { } definite)
+ {
+ response["definite_value"] = definite.Stringize();
+ response["definite_latex"] = definite.Latexize();
+ response["definite_caveat"] =
+ "Computed as F(to) - F(from) from the verified antiderivative. That is " +
+ "only valid when the integrand is continuous across the whole interval — " +
+ "it will happily return a finite, wrong number for something like " +
+ "1/x^2 across zero. Check for a singularity between the limits yourself, " +
+ "or ask am_domain_check.";
+ }
+
+ if (r.Item4 is { } q)
+ {
+ // Rounded to the digits two step counts agree on. Printing the library's
+ // full arbitrary-precision output here would be a hundred digits of a
+ // number correct to four, which is the failure this server exists to
+ // prevent — the caller cannot see the step count and would read it as exact.
+ response["numeric_definite_value"] = Math.Round(q.Value, q.Digits)
+ .ToString("G" + Math.Max(q.Digits + 1, 2),
+ System.Globalization.CultureInfo.InvariantCulture);
+ response["numeric_definite_error_bound"] = q.ErrorEstimate
+ .ToString("G3", System.Globalization.CultureInfo.InvariantCulture);
+ response["numeric_definite_caveat"] =
+ "APPROXIMATE, and produced only because the exact route gave nothing " +
+ "usable. It is quadrature over 4000 steps, cross-checked against 2000, " +
+ "and reported to the digits the two agree on — every digit shown is " +
+ "believed, and there are no more to be had from this call. Unlike the " +
+ "symbolic answer it is NOT verified by differentiating back. It also " +
+ "assumes the integrand is finite across the closed interval: the rule " +
+ "samples both endpoints, so a convergent integral with a singular " +
+ "endpoint returns nothing here rather than a value.";
+
+ if ((string?)response["status"] == "declined")
+ response["note"] = (string?)response["note"] +
+ " A numeric value is given in 'numeric_definite_value' instead; it " +
+ "answers this one interval and is not an antiderivative.";
+ }
+
+ return response;
+ });
+ }
+
+ private static JsonObject Limit(JsonObject args)
+ {
+ if (!TryParse(S(args, "expression"), "expression", false,
+ out var e, out var warnings, out var error))
+ return error!;
+ var variable = S(args, "variable");
+ var to = S(args, "to");
+ if (string.IsNullOrWhiteSpace(variable)) return Fail("'variable' is required");
+ if (string.IsNullOrWhiteSpace(to)) return Fail("'to' is required");
+
+ if (!TryParse(to, "to", false, out var destination, out _, out var destError))
+ return destError!;
+
+ var side = (S(args, "side") ?? "both").ToLowerInvariant() switch
+ {
+ "left" => ApproachFrom.Left,
+ "right" => ApproachFrom.Right,
+ _ => ApproachFrom.BothSides,
+ };
+
+ return FromOutcome(Guard.Run(() =>
+ {
+ var raw = e.Limit(Var(variable!), destination, side);
+ // Check for a decline BEFORE simplifying: an unevaluated limit(...) simplifies
+ // to NaN, which would look like a wrong answer instead of a missing solver.
+ return Guard.IsDeclined(raw.Stringize()) ? raw : raw.Simplify();
+ }), r =>
+ {
+ var response = Respond(e, r, warnings);
+ if ((string?)response["status"] == "suspect")
+ {
+ // NaN from a genuinely evaluated limit means "does not exist", which is a
+ // real answer rather than a library failure.
+ response["status"] = "no_limit";
+ response["note"] = "The limit evaluated to NaN, which here means the limit " +
+ "does not exist (e.g. 1/x as x -> 0 from both sides).";
+ }
+ return response;
+ });
+ }
+
+ private static JsonObject Evaluate(JsonObject args)
+ {
+ if (!TryParse(S(args, "expression"), "expression", false,
+ out var e, out var warnings, out var error))
+ return error!;
+
+ var substitutions = new List<(string Name, Entity Value)>();
+ if (args.TryGetPropertyValue("substitutions", out var subsNode) && subsNode is JsonObject subs)
+ {
+ foreach (var (key, value) in subs)
+ {
+ if (value is null) continue;
+ if (!TryParse(value.GetValue(), $"substitutions.{key}", false,
+ out var replacement, out _, out var subError))
+ return subError!;
+ substitutions.Add((key, replacement));
+ }
+ }
+
+ var digits = args.TryGetPropertyValue("digits", out var d) && d is not null
+ ? d.GetValue() : 0;
+ if (digits is < 0 or > 1000) return Fail("'digits' must be between 1 and 1000");
+
+ var input = e;
+ return FromOutcome(Guard.Run(() =>
+ {
+ // Widen the library's working precision for this call only. Without it the
+ // answer is computed at the default context and no amount of formatting can
+ // recover digits that were never computed.
+ var context = digits > 0
+ ? new PeterO.Numbers.EContext(digits, PeterO.Numbers.ERounding.HalfEven,
+ -int.MaxValue, int.MaxValue, false)
+ : MathS.Settings.DecimalPrecisionContext.Value;
+ using var precision = MathS.Settings.DecimalPrecisionContext.Set(context);
+
+ // Resolve calculus nodes BEFORE substituting. Substituting into
+ // derivative(f(x), x) cannot work — x is bound there — so `d/dx sqrt(1+x) at
+ // x=0` silently came back with x still free. Differentiate first, then plug in.
+ var resolved = input.InnerSimplified;
+ foreach (var (name, value) in substitutions)
+ resolved = resolved.Substitute(Var(name), value);
+
+ // Simplify, not Evaled: Evaled numericises aggressively, turning
+ // sin(pi/3) + cos(pi/6) into a hundred decimal digits and throwing away the
+ // exact sqrt(3) that is the whole reason to use a CAS.
+ var exact = resolved.Simplify();
+
+ string? approx = null;
+ if (exact.EvaluableNumerical)
+ {
+ var complex = exact.EvalNumerical();
+ approx = !complex.ImaginaryPart.EDecimal.IsZero
+ ? complex.Stringize()
+ : digits > 0
+ // Full computed precision, not a double — the whole point of asking.
+ ? complex.RealPart.EDecimal.ToString()
+ : complex.RealPart.EDecimal.ToDouble().ToString("R");
+ }
+ return (exact, approx);
+ }), r =>
+ {
+ var response = Respond(input, r.exact, warnings);
+ if (r.approx is not null) response["approximate"] = r.approx;
+ else response["note"] = "The expression is not fully numeric — it still contains " +
+ "free variables. Supply 'substitutions' to get a number.";
+ return response;
+ });
+ }
+
+ private static JsonObject VerifyEqual(JsonObject args)
+ {
+ if (!TryParse(S(args, "left"), "left", false, out var left, out var lw, out var le))
+ return le!;
+ if (!TryParse(S(args, "right"), "right", false, out var right, out var rw, out var re))
+ return re!;
+
+ return FromOutcome(Guard.Run(() =>
+ {
+ // Exact first: if the difference reduces to zero, that settles it. The zero test
+ // has to look through any `provided` guard — the difference of two equal
+ // expressions is routinely "0 provided x > 0", which is still zero.
+ var difference = (left - right).Simplify();
+ var exactlyZero = Numeric.IsZero(difference);
+
+ bool? numerically = null;
+ bool? wholeLine = null;
+
+ // Trust, then verify the verifier. The exact path believes Simplify, and Simplify
+ // can be unsound: it reduces sqrt(x^2) to x, so `sqrt(x^2) = x` comes back
+ // "exact: difference simplified to 0" — while the library's own evaluator gives
+ // sqrt(x^2) = 2 at x = -2. Sampling the ORIGINAL sides across the real line
+ // catches a bad simplification, because it never goes through one.
+ bool? conflict = null;
+ if (exactlyZero)
+ {
+ try
+ {
+ var direct = Numeric.Equal(left, right,
+ ["-3.1", "-1.7", "-0.4", "0.8", "2.6"]);
+ if (direct == false) conflict = true;
+ }
+ catch { /* no counter-evidence available */ }
+ }
+
+ if (!exactlyZero)
+ {
+ // Compare the DIFFERENCE against zero rather than the two sides against
+ // each other. Simplify has already collapsed any unevaluated derivative(…)
+ // or integral(…) node in the difference, whereas the raw sides may still
+ // contain one — and an unevaluated node is not numerically evaluable, so
+ // comparing the sides directly returns "inconclusive" for inputs that are
+ // in fact decidable.
+ try { numerically = Numeric.Equal(difference, 0); }
+ catch { numerically = null; }
+
+ // The positive-real points above cannot see a disagreement that only occurs
+ // on the negatives (abs(x) vs x, say). A second pass across the whole real
+ // line catches that.
+ if (numerically == true)
+ {
+ try
+ {
+ wholeLine = Numeric.Equal(difference, 0,
+ ["-3.1", "-1.7", "-0.4", "0.8", "2.6"]);
+ }
+ catch { wholeLine = null; }
+ }
+ }
+
+ return (difference, exactlyZero, numerically, wholeLine, conflict);
+ }), r =>
+ {
+ var equal = r.exactlyZero || r.numerically == true;
+ // Inconclusive is reported as null, never as false: "we could not check" is not
+ // the same claim as "they differ", and a caller acting on a bare false would be
+ // acting on something the server never established.
+ var conclusive = r.exactlyZero || r.numerically is not null;
+
+ var response = new JsonObject
+ {
+ ["status"] = conclusive ? "solved" : "inconclusive",
+ ["equal"] = conclusive ? equal : null,
+ ["method"] = r.exactlyZero
+ ? "exact: left - right simplified to 0"
+ : r.numerically is null
+ ? "inconclusive: neither side could be evaluated at any sample point"
+ : "numeric: sampled at five positive real points, relative tolerance 1e-6",
+ ["difference"] = r.difference.Stringize(),
+ ["left_parsed"] = left.Stringize(),
+ ["right_parsed"] = right.Stringize(),
+ ["note"] = equal
+ ? "The two expressions agree."
+ : r.numerically is null
+ ? "Could not establish equality either way. Not proof of inequality."
+ : "The expressions differ. Check 'difference' to see by how much.",
+ ["warnings"] = Warn([.. lw, .. rw]),
+ };
+
+ if (r.conflict == true)
+ {
+ response["status"] = "conflict";
+ response["equal"] = false;
+ response["conflict"] = true;
+ response["note"] =
+ "Symbolic simplification says these are equal, but evaluating the two " +
+ "sides directly disagrees at sampled points on the real line. The " +
+ "direct evaluation is the more trustworthy of the two, since it does " +
+ "not pass through a rewrite. Treat them as NOT equal, and treat the " +
+ "simplification as suspect — sqrt(x^2) reducing to x rather than abs(x) " +
+ "is a known instance.";
+ }
+ else if (equal && r.wholeLine == false)
+ {
+ response["equal_only_on_positive_reals"] = true;
+ response["note"] =
+ "The expressions agree for positive real inputs but disagree elsewhere " +
+ "on the real line — the usual cause is a logarithm, square root or " +
+ "absolute value. Treat them as equal only if your domain is restricted " +
+ "to the positives.";
+ }
+
+ return response;
+ });
+ }
+
+ private static JsonObject TruthTable(JsonObject args)
+ {
+ if (!TryParse(S(args, "expression"), "expression", false,
+ out var e, out var warnings, out var error))
+ return error!;
+
+ var names = A(args, "variables");
+ var vars = names.Count > 0
+ ? names.Select(Var).ToArray()
+ : e.Vars.ToArray();
+
+ if (vars.Length == 0) return Fail("no variables found in the expression");
+ if (vars.Length > 12) return Fail($"{vars.Length} variables would need {1L << vars.Length} rows; cap is 12");
+
+ return FromOutcome(Guard.Run(() =>
+ {
+ var table = MathS.Boolean.BuildTruthTable(e, vars);
+ var satisfying = MathS.SolveBooleanTable(e, vars);
+ return (table, satisfying);
+ }), r =>
+ {
+ var order = new JsonArray();
+ foreach (var v in vars) order.Add(v.Stringize());
+
+ var response = new JsonObject
+ {
+ ["status"] = r.table is null ? "failed" : "solved",
+ ["parsed"] = e.Stringize(),
+ ["variable_order"] = order,
+ ["truth_table"] = r.table?.ToString(multilineFormat: true),
+ ["satisfiable"] = r.satisfying is not null,
+ ["satisfying_assignments"] = r.satisfying?.ToString(multilineFormat: true),
+ };
+ if (r.satisfying is null)
+ response["note"] = "No assignment satisfies this expression — it is a contradiction.";
+ if (warnings.Count > 0) response["warnings"] = Warn(warnings);
+ return response;
+ });
+ }
+
+ private static JsonObject SolveSystem(JsonObject args)
+ {
+ var equations = A(args, "equations");
+ var variables = A(args, "variables");
+ if (equations.Count == 0) return Fail("'equations' must not be empty");
+ if (variables.Count == 0) return Fail("'variables' must not be empty");
+
+ var parsed = new List();
+ var warnings = new List();
+ foreach (var eq in equations)
+ {
+ if (!TryParse(eq, "equations", false, out var e, out var w, out var error))
+ return error!;
+
+ // EquationSystem wants each equation in "= 0" form, and handed an equality it
+ // finds no solution, so normalise here: writing 'x + y = 3' is the natural thing
+ // for a caller to do, and it should just work.
+ // https://github.com/asc-community/AngouriMath/issues/1673
+ parsed.Add(e is Equalsf equality ? equality.Left - equality.Right : e);
+ warnings.AddRange(w);
+ }
+
+ return FromOutcome(Guard.Run(() =>
+ MathS.Equations([.. parsed]).Solve([.. variables.Select(Var)])), matrix =>
+ {
+ var order = new JsonArray();
+ foreach (var v in variables) order.Add(v);
+
+ var response = new JsonObject
+ {
+ ["status"] = matrix is null ? "declined" : "solved",
+ ["variable_order"] = order,
+ ["solutions"] = matrix?.ToString(multilineFormat: true),
+ ["solution_count"] = matrix?.RowCount,
+ };
+
+ if (matrix is null)
+ response["note"] = "No solution was returned. A nonlinear system can come " +
+ "back with nothing even when it has a solution, so absence here is not " +
+ "proof that the system is unsolvable.";
+
+ if (warnings.Count > 0) response["warnings"] = Warn(warnings);
+ return response;
+ });
+ }
+
+ private static JsonObject CheckSteps(JsonObject args)
+ {
+ var raw = A(args, "steps");
+ if (raw.Count < 2) return Fail("'steps' needs at least two entries to compare");
+
+ var steps = new List();
+ var warnings = new List();
+ foreach (var step in raw)
+ {
+ if (!TryParse(step, "steps", false, out var e, out var w, out var error))
+ return error!;
+ steps.Add(e);
+ warnings.AddRange(w);
+ }
+
+ return FromOutcome(Guard.Run(() => Analysis.CheckSteps(steps)), checks =>
+ {
+ var rows = new JsonArray();
+ int? firstBad = null;
+
+ foreach (var check in checks)
+ {
+ rows.Add(new JsonObject
+ {
+ ["step"] = check.Index,
+ ["from"] = check.From,
+ ["to"] = check.To,
+ ["equal"] = check.Equal,
+ ["method"] = check.Method,
+ ["difference"] = check.Difference,
+ });
+ if (firstBad is null && check.Equal == false) firstBad = check.Index;
+ }
+
+ var response = new JsonObject
+ {
+ ["status"] = "solved",
+ ["all_steps_valid"] = firstBad is null,
+ ["first_invalid_step"] = firstBad,
+ ["steps"] = rows,
+ ["note"] = firstBad is null
+ ? "Every step preserves the value. The working is sound — note this " +
+ "checks equality, not whether the steps are the clearest route."
+ : $"Step {firstBad} changes the value. Everything before it is sound, " +
+ "so the error is introduced exactly there.",
+ };
+ if (warnings.Count > 0) response["warnings"] = Warn(warnings);
+ return response;
+ });
+ }
+
+ private static JsonObject DomainCheck(JsonObject args)
+ {
+ if (!TryParse(S(args, "expression"), "expression", false,
+ out var e, out var warnings, out var error))
+ return error!;
+
+ return FromOutcome(Guard.Run(() =>
+ {
+ Entity simplified;
+ try { simplified = e.Simplify(); }
+ catch { simplified = e; }
+
+ return (simplified,
+ Analysis.Conditions(simplified),
+ Analysis.Hazards(e),
+ Analysis.Reality(e));
+ }), r =>
+ {
+ var conditions = new JsonArray();
+ foreach (var c in r.Item2) conditions.Add(c);
+
+ var hazards = new JsonArray();
+ foreach (var h in r.Item3)
+ hazards.Add(new JsonObject { ["in"] = h.Where, ["risk"] = h.Risk });
+
+ var complexAt = new JsonArray();
+ foreach (var p in r.Item4.ComplexAt) complexAt.Add(p);
+ var undefinedAt = new JsonArray();
+ foreach (var p in r.Item4.UndefinedAt) undefinedAt.Add(p);
+
+ var response = new JsonObject
+ {
+ ["status"] = "solved",
+ ["parsed"] = e.Stringize(),
+ ["simplified"] = r.simplified.Stringize(),
+ ["conditions"] = conditions,
+ ["hazards"] = hazards,
+ ["not_real_at"] = complexAt,
+ ["undefined_at"] = undefinedAt,
+ ["points_sampled"] = r.Item4.Sampled,
+ };
+
+ if (r.Item2.Count > 0)
+ response["conditions_note"] = "AngouriMath derived these guards itself. They " +
+ "are part of the answer — dropping them makes the result wrong on the " +
+ "excluded points.";
+
+ if (r.Item4.ComplexAt.Count > 0 || r.Item4.UndefinedAt.Count > 0)
+ response["reality_note"] = "The expression leaves the reals at some sampled " +
+ "points. If it stands for a physical quantity, constrain the input " +
+ "range before using it — pass the constraint to am_solve alongside the " +
+ "equation.";
+
+ if (warnings.Count > 0) response["warnings"] = Warn(warnings);
+ return response;
+ });
+ }
+
+ private static JsonObject Represent(JsonObject args)
+ {
+ var operation = S(args, "operation");
+ if (string.IsNullOrWhiteSpace(operation)) return Fail("'operation' is required");
+
+ return operation switch
+ {
+ "base" => RepresentBase(args),
+ "fixed_point" => RepresentFixed(args),
+ "from_fixed_point" => RepresentFromFixed(args),
+ "ieee754" => RepresentIeee(args),
+ "polar" => RepresentPolar(args),
+ "rectangular" => RepresentRectangular(args),
+ _ => Fail($"unknown operation '{operation}'"),
+ };
+ }
+
+ private static JsonObject RepresentBase(JsonObject args)
+ {
+ var value = S(args, "value");
+ if (string.IsNullOrWhiteSpace(value)) return Fail("'value' is required");
+
+ var fromBase = args.TryGetPropertyValue("from_base", out var f) && f is not null
+ ? f.GetValue() : 10;
+ if (!args.TryGetPropertyValue("to_base", out var t) || t is null)
+ return Fail("'to_base' is required for operation 'base'");
+ var toBase = t.GetValue();
+
+ if (fromBase is < 2 or > 36) return Fail("'from_base' must be between 2 and 36");
+ if (toBase is < 2 or > 36) return Fail("'to_base' must be between 2 and 36");
+
+ return FromOutcome(Guard.Run(() =>
+ {
+ var asDecimal = fromBase == 10
+ ? value!.ToEntity()
+ : MathS.FromBaseN(value!, fromBase);
+
+ var real = (Number.Real)asDecimal.Evaled;
+ var converted = toBase == 10 ? real.Stringize() : MathS.ToBaseN(real, toBase);
+ return (real, converted);
+ }), r => new JsonObject
+ {
+ ["status"] = "solved",
+ ["operation"] = "base",
+ ["input"] = value,
+ ["from_base"] = fromBase,
+ ["to_base"] = toBase,
+ ["decimal"] = r.real.Stringize(),
+ ["result"] = r.converted,
+ });
+ }
+
+ /// Evaluate an expression to an exact decimal, or explain why not.
+ private static bool TryDecimal(JsonObject args, string key,
+ out PeterO.Numbers.EDecimal value, out JsonObject? error)
+ {
+ value = PeterO.Numbers.EDecimal.Zero;
+ if (!TryParse(S(args, key), key, false, out var e, out _, out error)) return false;
+
+ if (!e.EvaluableNumerical)
+ {
+ error = Fail($"'{key}' must evaluate to a number; got '{e.Stringize()}'");
+ return false;
+ }
+
+ var complex = e.EvalNumerical();
+ if (!complex.ImaginaryPart.EDecimal.IsZero)
+ {
+ error = Fail($"'{key}' must be real; got '{complex.Stringize()}'");
+ return false;
+ }
+
+ value = complex.RealPart.EDecimal;
+ return true;
+ }
+
+ private static JsonObject RepresentFixed(JsonObject args)
+ {
+ if (!TryDecimal(args, "value", out var value, out var error)) return error!;
+
+ var totalBits = args.TryGetPropertyValue("total_bits", out var tb) && tb is not null
+ ? tb.GetValue() : 16;
+ if (!args.TryGetPropertyValue("fraction_bits", out var fb) || fb is null)
+ return Fail("'fraction_bits' is required for 'fixed_point'");
+ var fractionBits = fb.GetValue();
+ var signed = B(args, "signed", true);
+
+ if (totalBits is < 2 or > 128) return Fail("'total_bits' must be between 2 and 128");
+ if (fractionBits < 0 || fractionBits > totalBits)
+ return Fail("'fraction_bits' must be between 0 and 'total_bits'");
+
+ return FromOutcome(
+ Guard.Run(() => Representations.ToFixed(value, totalBits, fractionBits, signed)),
+ fixedPoint =>
+ {
+ var response = new JsonObject
+ {
+ ["status"] = fixedPoint.Saturated ? "suspect" : "solved",
+ ["operation"] = "fixed_point",
+ ["format"] = $"Q{totalBits - fractionBits - (signed ? 1 : 0)}.{fractionBits}"
+ + $" in {totalBits} bits, {(signed ? "signed" : "unsigned")}",
+ ["raw"] = fixedPoint.Raw.ToString(),
+ ["raw_hex"] = "0x" + fixedPoint.Raw.Abs().ToRadixString(16).ToUpperInvariant(),
+ ["represented_value"] = fixedPoint.Represented.Stringize(),
+ ["absolute_error"] = fixedPoint.Error.ToString(),
+ ["relative_error"] = fixedPoint.RelativeError.ToString(),
+ ["resolution"] = fixedPoint.Resolution.ToString(),
+ ["representable_range"] =
+ $"[{fixedPoint.Min}, {fixedPoint.Max}] raw",
+ ["saturated"] = fixedPoint.Saturated,
+ };
+
+ if (fixedPoint.Saturated)
+ response["note"] = "The value does not fit and was clamped to the limit. " +
+ "On most processors this is a design-time bug rather than something " +
+ "to accept — widen the word, or move the binary point.";
+ else
+ response["note"] = "'represented_value' is exact: an integer over a power " +
+ "of two is a rational, so there is no second rounding. Use " +
+ "am_compare_numeric to see what this quantisation costs across your " +
+ "operating range rather than at a single point.";
+
+ return response;
+ });
+ }
+
+ private static JsonObject RepresentFromFixed(JsonObject args)
+ {
+ if (!TryDecimal(args, "value", out var value, out var error)) return error!;
+ if (!args.TryGetPropertyValue("fraction_bits", out var fb) || fb is null)
+ return Fail("'fraction_bits' is required for 'from_fixed_point'");
+ var fractionBits = fb.GetValue();
+ if (fractionBits is < 0 or > 128) return Fail("'fraction_bits' must be between 0 and 128");
+
+ var raw = value.ToEInteger();
+
+ return FromOutcome(
+ Guard.Run(() => Representations.FromFixed(raw, fractionBits)),
+ v => new JsonObject
+ {
+ ["status"] = "solved",
+ ["operation"] = "from_fixed_point",
+ ["raw"] = raw.ToString(),
+ ["fraction_bits"] = fractionBits,
+ ["result"] = v.Stringize(),
+ ["approximate"] = v.EvaluableNumerical
+ ? v.EvalNumerical().RealPart.EDecimal.ToDouble().ToString("R")
+ : null,
+ });
+ }
+
+ private static JsonObject RepresentIeee(JsonObject args)
+ {
+ if (!TryDecimal(args, "value", out var value, out var error)) return error!;
+ var single = (S(args, "precision") ?? "double") == "single";
+
+ return FromOutcome(
+ Guard.Run(() => Representations.Decompose(value, single)),
+ ieee => new JsonObject
+ {
+ ["status"] = "solved",
+ ["operation"] = "ieee754",
+ ["precision"] = single ? "single (32-bit)" : "double (64-bit)",
+ ["bits"] = ieee.Bits,
+ ["hex"] = ieee.Hex,
+ ["sign"] = ieee.Sign,
+ ["exponent_raw"] = ieee.RawExponent,
+ ["exponent_unbiased"] = ieee.UnbiasedExponent,
+ ["mantissa_hex"] = ieee.MantissaHex,
+ ["exact_value"] = ieee.ExactValue,
+ ["error_vs_input"] = ieee.Error.ToString(),
+ ["classification"] = ieee.Classification,
+ ["note"] = "'exact_value' is what the format actually stores, not a rounded " +
+ "display of it. That difference is the whole reason 0.1 + 0.2 does not " +
+ "equal 0.3.",
+ });
+ }
+
+ private static JsonObject RepresentPolar(JsonObject args)
+ {
+ if (!TryParse(S(args, "value"), "value", false,
+ out var e, out var warnings, out var error))
+ return error!;
+
+ return FromOutcome(Guard.Run(() =>
+ {
+ var evaluated = e.Evaled;
+ if (evaluated is not Number.Complex complex)
+ throw new ArgumentException($"'{e.Stringize()}' is not a number");
+
+ return Representations.ToPolar(complex.RealPart, complex.ImaginaryPart);
+ }), polar =>
+ {
+ var response = new JsonObject
+ {
+ ["status"] = "solved",
+ ["operation"] = "polar",
+ ["magnitude"] = polar.Magnitude.Stringize(),
+ ["phase_radians"] = polar.Phase.Stringize(),
+ ["phase_degrees"] = polar.PhaseDegrees.Stringize(),
+ ["note"] = "Phase is quadrant-corrected — arctan alone would report the same " +
+ "angle for 1+i and -1-i.",
+ };
+ if (warnings.Count > 0) response["warnings"] = Warn(warnings);
+ return response;
+ });
+ }
+
+ private static JsonObject RepresentRectangular(JsonObject args)
+ {
+ if (!TryParse(S(args, "magnitude"), "magnitude", false,
+ out var magnitude, out var warnings, out var magnitudeError))
+ return magnitudeError!;
+ if (!TryParse(S(args, "phase"), "phase", false,
+ out var phase, out var more, out var phaseError))
+ return phaseError!;
+ warnings.AddRange(more);
+
+ return FromOutcome(
+ Guard.Run(() => Representations.FromPolar(magnitude, phase)),
+ rectangular =>
+ {
+ var response = new JsonObject
+ {
+ ["status"] = "solved",
+ ["operation"] = "rectangular",
+ ["result"] = rectangular.Stringize(),
+ ["latex"] = rectangular.Latexize(),
+ };
+ if (warnings.Count > 0) response["warnings"] = Warn(warnings);
+ return response;
+ });
+ }
+
+ private static JsonObject MatrixOp(JsonObject args)
+ {
+ var operation = S(args, "operation");
+ if (string.IsNullOrWhiteSpace(operation)) return Fail("'operation' is required");
+
+ args.TryGetPropertyValue("matrix", out var aNode);
+ var a = Matrices.Parse(aNode, "matrix");
+ if (a.Value is null) return Fail(a.Error!);
+
+ var needsSecond = operation is "multiply" or "add" or "subtract" or "tensor_product";
+ Matrix? b = null;
+ var warnings = new List(a.Warnings);
+
+ if (needsSecond)
+ {
+ args.TryGetPropertyValue("matrix_b", out var bNode);
+ var parsedB = Matrices.Parse(bNode, "matrix_b");
+ if (parsedB.Value is null)
+ return Fail(parsedB.Error ?? $"'{operation}' needs 'matrix_b'");
+ b = parsedB.Value;
+ warnings.AddRange(parsedB.Warnings);
+ }
+
+ var exponent = args.TryGetPropertyValue("exponent", out var e) && e is not null
+ ? e.GetValue() : -1;
+ if (operation == "power" && exponent < 0)
+ return Fail("'power' needs a non-negative 'exponent'");
+
+ var guards = new List();
+
+ return FromOutcome(Guard.Run