diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index c8e89ff07..83a62c6da 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -42,6 +42,20 @@ A not-equal written `<>` is read as `not (a = b)`, and printed that way. ## Unreleased — since 2.6.0 +### `am_classify` is a preliminary tool, served by `amcli mcp --preliminary` + +**Not silent: what answered is a failure naming the flag.** `am_classify` guesses which field a formula comes from by +the names of its symbols, the one answer of the MCP server that the library does not compute. It is preliminary now: +its arguments and answers may change in a minor release. `amcli mcp` no longer lists it, and answers a call to it with +`failed`. `amcli mcp --preliminary` lists it with a description beginning `Preliminary:`, and each answer carries +`"preliminary": true` ([#2113](https://github.com/asc-community/AngouriMath/pull/2113)). + +| Request | Was (2.6.0) | Now | +|---|---|---| +| `tools/list` to `amcli mcp` | 23 tools, `am_classify` among them | 22 tools | +| `am_classify` on `G*M*m/r^2`, to `amcli mcp` | `solved`, `Newtonian gravitation` first | `failed`: `'am_classify' is preliminary, and is served only when the server is started as 'amcli mcp --preliminary'` | +| the same, to `amcli mcp --preliminary` | `solved` (the flag is ignored) | `solved`, and `"preliminary": true` | + ### A subscript may have a subscript **Not silent: what was a parse error is a name.** A name took at most one `_`, so `x_1_2` and `X_a_beta` were lexer diff --git a/Sources/MCP/AngouriMath.Mcp/Server.cs b/Sources/MCP/AngouriMath.Mcp/Server.cs index 0566abc1a..36385b5a1 100644 --- a/Sources/MCP/AngouriMath.Mcp/Server.cs +++ b/Sources/MCP/AngouriMath.Mcp/Server.cs @@ -55,12 +55,15 @@ public static int Run(IReadOnlyList arguments, TextReader input, TextWri { 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"); + output.WriteLine(" amcli mcp serve on stdin/stdout"); + output.WriteLine(" amcli mcp --preliminary serve the preliminary tools as well: " + string.Join(", ", Tools.Preliminary.Order(StringComparer.Ordinal))); + output.WriteLine(" amcli mcp --selftest verify the install and check the docs for drift"); + output.WriteLine(" amcli mcp --help this message"); return 0; } + var preliminary = arguments.Contains("--preliminary"); + var serializerOptions = new JsonSerializerOptions { WriteIndented = false }; void Send(JsonObject message) @@ -105,7 +108,7 @@ void Send(JsonObject message) try { - var result = Dispatch(method, parameters, error); + var result = Dispatch(method, parameters, preliminary, error); if (result is null) { if (!isNotification) @@ -127,7 +130,7 @@ void Send(JsonObject message) return 0; } - private static JsonObject? Dispatch(string method, JsonObject? parameters, TextWriter error) + private static JsonObject? Dispatch(string method, JsonObject? parameters, bool preliminary, TextWriter error) { switch (method) { @@ -167,7 +170,7 @@ void Send(JsonObject message) return new JsonObject(); case "tools/list": - return new JsonObject { ["tools"] = Tools.List() }; + return new JsonObject { ["tools"] = Tools.List(preliminary) }; case "tools/call": { @@ -180,7 +183,7 @@ void Send(JsonObject message) JsonObject payload; try { - payload = Tools.Call(name, arguments); + payload = Tools.Call(name, arguments, preliminary); } catch (Exception e) { diff --git a/Sources/MCP/AngouriMath.Mcp/Tools.cs b/Sources/MCP/AngouriMath.Mcp/Tools.cs index 40c933e54..5f6e6c855 100644 --- a/Sources/MCP/AngouriMath.Mcp/Tools.cs +++ b/Sources/MCP/AngouriMath.Mcp/Tools.cs @@ -54,7 +54,40 @@ private static JsonObject Schema(JsonObject props, params string[] required) "manipulation is unreliable for exactly this class of problem, and the result here is " + "machine-checked."; - public static JsonArray List() => + /// + /// The preliminary tools: served and working as described, but with arguments and answers that + /// may change in a minor release, without an entry in BREAKING-CHANGES.md. A client is offered + /// them only when the server is started with --preliminary, and each is marked where a + /// model reads it: its description begins , and its every answer + /// carries "preliminary": true. + /// + /// + /// am_classify reads a formula's field off the names of its symbols. That is a guess + /// rather than a computation, whose tables change whenever a field is added, and no test pins + /// what it answers. + /// + public static readonly IReadOnlySet Preliminary = new HashSet(StringComparer.Ordinal) { "am_classify" }; + + private const string PreliminaryMark = "Preliminary: its arguments and answers may change in a minor release. "; + + /// The tools offered to a client: every one, or all but the preliminary ones. + public static JsonArray List(bool preliminary) + { + var listed = new JsonArray(); + foreach (var tool in All()) + { + if (!Preliminary.Contains(tool["name"]!.GetValue())) + listed.Add(tool); + else if (preliminary) + { + tool["description"] = PreliminaryMark + tool["description"]!.GetValue(); + listed.Add(tool); + } + } + return listed; + } + + private static JsonObject[] All() => [ Tool("am_parse", "Parse an expression and show exactly how AngouriMath understood it. Use this " + @@ -480,7 +513,22 @@ public static JsonArray List() => // ---------------------------------------------------------------- dispatch - public static JsonObject Call(string name, JsonObject args) => name switch + /// + /// What a tool answers. A preliminary one answers only when is + /// set, and its answer says that it is preliminary. + /// + public static JsonObject Call(string name, JsonObject args, bool preliminary) + { + if (!Preliminary.Contains(name)) + return Answer(name, args); + if (!preliminary) + return Fail($"'{name}' is preliminary, and is served only when the server is started as 'amcli mcp --preliminary'"); + var payload = Answer(name, args); + payload["preliminary"] = true; + return payload; + } + + private static JsonObject Answer(string name, JsonObject args) => name switch { "am_parse" => Parse(args), "am_simplify" => Simplify(args), diff --git a/Sources/MCP/README.md b/Sources/MCP/README.md index 856d6ccba..125e10078 100644 --- a/Sources/MCP/README.md +++ b/Sources/MCP/README.md @@ -71,7 +71,6 @@ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | amcli mcp | `am_expand`, `am_factor` | brackets out, or back into a product | | `am_series` | Taylor or Maclaurin, to a given degree | | `am_number_theory` | factorisation, totient, gcd, divisor count, primality | -| `am_classify` | which field a formula comes from, read off its symbols | | `am_to_sympy` | a runnable SymPy program, for cross-checking | Five **prompts** surface as slash commands in a client: `verify-derivation`, `check-formula`, @@ -84,6 +83,27 @@ Three **resources**: `angourimath://syntax`, the grammar and its two silent trap `angourimath://curiosities`, famous results with the call that reproduces each. Point a model at the first two at the start of a session. +### Preliminary tools + +A preliminary tool works as described, but its arguments and answers may change in a minor release, +without an entry in [BREAKING-CHANGES.md](../../BREAKING-CHANGES.md). A server offers one only when +it is started with `--preliminary`: + +```sh +claude mcp add angourimath --scope user -- amcli mcp --preliminary +``` + +It is marked where a model reads it: its description begins `Preliminary:`, and each of its answers +carries `"preliminary": true`. Called on a server started without the flag, it answers `failed` and +names the flag. + +| Tool | | +|---|---| +| `am_classify` | which field a formula comes from, read off its symbols: a guess from naming conventions, not a computation | + +A tool is preliminary while its name is in `Tools.Preliminary`. Taking it out makes it stable, and +from then on a change to what it answers is a breaking change. + ## What the design rests on **Every response echoes the parse.** The parser is permissive in two silent ways, and silence is diff --git a/Sources/Tests/McpUnitTests/Mcp.cs b/Sources/Tests/McpUnitTests/Mcp.cs index 5b46254e1..e752c0e78 100644 --- a/Sources/Tests/McpUnitTests/Mcp.cs +++ b/Sources/Tests/McpUnitTests/Mcp.cs @@ -17,12 +17,18 @@ internal static class Mcp /// The replies to , one per line the server wrote, and everything it /// wrote to the error stream. /// - public static (List Replies, string Errors) Session(params string[] lines) + public static (List Replies, string Errors) Session(params string[] lines) => Session(false, lines); + + /// + /// The same, from a server started as amcli mcp --preliminary when + /// is set. + /// + public static (List Replies, string Errors) Session(bool preliminary, params string[] lines) { using var input = new StringReader(string.Join("\n", lines)); using var output = new StringWriter(); using var error = new StringWriter(); - Assert.Equal(0, Server.Run([], input, output, error)); + Assert.Equal(0, Server.Run(preliminary ? ["--preliminary"] : [], input, output, error)); var replies = output.ToString() .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(line => JsonNode.Parse(line) as JsonObject ?? throw new InvalidOperationException($"not an object: {line}")) @@ -31,9 +37,9 @@ public static (List Replies, string Errors) Session(params string[] } /// The result of one request with id 1, after initialize. - public static JsonObject Request(string method, string parametersJson = "{}") + public static JsonObject Request(string method, string parametersJson = "{}", bool preliminary = false) { - var (replies, errors) = Session( + var (replies, errors) = Session(preliminary, """{"jsonrpc":"2.0","id":0,"method":"initialize","params":{}}""", $$"""{"jsonrpc":"2.0","id":1,"method":"{{method}}","params":{{parametersJson}}}"""); Assert.Equal("", errors); @@ -44,9 +50,9 @@ public static JsonObject Request(string method, string parametersJson = "{}") /// /// What one tool answered: the JSON in its text content, which is what a model reads. /// - public static JsonObject Call(string tool, string argumentsJson) + public static JsonObject Call(string tool, string argumentsJson, bool preliminary = false) { - var result = Request("tools/call", $$"""{"name":"{{tool}}","arguments":{{argumentsJson}}}"""); + var result = Request("tools/call", $$"""{"name":"{{tool}}","arguments":{{argumentsJson}}}""", preliminary); var text = result["content"]?[0]?["text"]?.GetValue() ?? throw new InvalidOperationException($"{tool} answered {result.ToJsonString()}"); return JsonNode.Parse(text) as JsonObject ?? throw new InvalidOperationException($"{tool} answered {text}"); diff --git a/Sources/Tests/McpUnitTests/PreliminaryTest.cs b/Sources/Tests/McpUnitTests/PreliminaryTest.cs new file mode 100644 index 000000000..306cd2519 --- /dev/null +++ b/Sources/Tests/McpUnitTests/PreliminaryTest.cs @@ -0,0 +1,66 @@ +// +// 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 Xunit; + +namespace AngouriMath.Mcp.Tests; + +/// +/// A preliminary tool may change its arguments and answers in a minor release. It is offered only +/// by a server started as amcli mcp --preliminary, and says that it is preliminary wherever +/// a model reads it: in its description, and in each answer. +/// +public sealed class PreliminaryTest +{ + private const string Gravitation = """{"expression":"G*M*m/r^2"}"""; + + private static Dictionary Listed(bool preliminary) => + Mcp.Request("tools/list", preliminary: preliminary)["tools"]!.AsArray() + .ToDictionary(tool => tool!["name"]!.GetValue(), tool => tool!["description"]!.GetValue()); + + [Fact] + public void APreliminaryToolIsListedOnlyWhenAskedFor() + { + var stable = Listed(preliminary: false); + var all = Listed(preliminary: true); + + Assert.DoesNotContain("am_classify", stable.Keys); + Assert.Equal(stable.Keys.Append("am_classify").Order(), all.Keys.Order()); + Assert.StartsWith("Preliminary:", all["am_classify"]); + Assert.All(stable, tool => Assert.Equal(tool.Value, all[tool.Key])); + Assert.DoesNotContain(stable.Values, description => description.StartsWith("Preliminary", StringComparison.Ordinal)); + } + + [Fact] + public void APreliminaryToolCalledWithoutTheFlagSaysHowToAskForIt() + { + var answer = Mcp.Call("am_classify", Gravitation); + Assert.Equal("failed", answer.Field("status")); + Assert.Contains("amcli mcp --preliminary", answer.Field("error")); + Assert.Null(answer["likely_fields"]); + } + + [Fact] + public void APreliminaryToolsAnswerSaysItIsPreliminary() + { + var answer = Mcp.Call("am_classify", Gravitation, preliminary: true); + Assert.Equal("solved", answer.Field("status")); + Assert.True(answer["preliminary"]!.GetValue()); + Assert.Equal("Newtonian gravitation", answer["likely_fields"]![0]!["field"]!.GetValue()); + + Assert.Null(Mcp.Call("am_parse", """{"expression":"x + 1"}""", preliminary: true)["preliminary"]); + } + + [Fact] + public void TheHelpNamesThePreliminaryTools() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + Assert.Equal(0, Server.Run(["--help"], new StringReader(""), output, error)); + Assert.Contains("amcli mcp --preliminary serve the preliminary tools as well: am_classify", output.ToString()); + } +} diff --git a/Sources/Tests/McpUnitTests/ServerTest.cs b/Sources/Tests/McpUnitTests/ServerTest.cs index 040aad48e..e4aacc56c 100644 --- a/Sources/Tests/McpUnitTests/ServerTest.cs +++ b/Sources/Tests/McpUnitTests/ServerTest.cs @@ -69,7 +69,7 @@ public void EveryToolIsReadOnlyAndClosedWorld() { // An MCP client can approve these without asking, which is what gets a maths tool // used at all. - var tools = Mcp.Request("tools/list")["tools"]!.AsArray(); + var tools = Mcp.Request("tools/list", preliminary: true)["tools"]!.AsArray(); Assert.Equal(23, tools.Count); foreach (var tool in tools) {