| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653 |
- package com.nonvioce.cal;
- import android.util.Log;
- import fi.iki.elonen.NanoHTTPD;
- import org.json.JSONArray;
- import org.json.JSONException;
- import org.json.JSONObject;
- import java.io.IOException;
- import java.util.ArrayList;
- import java.util.List;
- /**
- * MCP (Model Context Protocol) server embedded in the Android app.
- * Implements JSON-RPC 2.0 over HTTP on localhost, exposing Symja math
- * computation as MCP tools and resources.
- *
- * 与 Ijs 版本的关键区别:本工程剥离了所有插件 / JavaScript 能力(设计决策 Q18:
- * 插件是开发者侧的 Dart 编译期模块,MCP 不应具备插件管理与代码执行能力)。
- * 因此这里既没有 Rhino,也没有 PluginManager,只剩纯计算工具。
- */
- public class McpServer extends NanoHTTPD {
- private static final String TAG = "McpServer";
- private static final String PROTOCOL_VERSION = "2025-03-26";
- private static final String SERVER_NAME = "cal-mcp";
- private static final String SERVER_VERSION = "1.0";
- /**
- * 单次求值的超时(毫秒)。
- *
- * 对齐旧实现 callEvaluate 使用的 15000ms:Symja 的符号计算(积分、解方程)
- * 可能很慢,而 MCP 客户端本身也会超时,15s 是"够用又不至于把请求挂死"的折中。
- */
- private static final long EVAL_TIMEOUT_MS = 15000L;
- /** 最近 100 条求值记录,供 history://recent 资源读取。 */
- private final List<String> evalHistory = java.util.Collections.synchronizedList(new ArrayList<>());
- private volatile boolean initialized = false;
- /**
- * 监听**所有网卡**(NanoHTTPD 传 port 而不传 hostname 就是通配地址)。
- *
- * 旧实现(Ijs)绑的是 127.0.0.1,那样只有本机进程或 adb forward 能连上。
- * 现在改成全网卡,同网段的 AI 客户端可以直接连 `http://<手机 IP>:8734/mcp`。
- *
- * 代价要说清楚:**同网段的任何人都能调用这个计算引擎**。服务本身只读 + 求值
- * (没有任何写文件或执行代码的能力),但可以被拿来跑重计算(比如难解的积分)
- * 占住那条唯一的求值线程。要收回来就改回 super("127.0.0.1", port)。
- */
- public McpServer(int port) {
- super(port);
- }
- private String sessionId = java.util.UUID.randomUUID().toString();
- // ── HTTP routing ──────────────────────────────────────────────
- @Override
- public Response serve(IHTTPSession session) {
- String method = session.getMethod().name();
- String uri = session.getUri();
- // CORS preflight
- if ("OPTIONS".equals(method)) {
- Response r = newFixedLengthResponse(Response.Status.OK, "application/json", "{}");
- addCommonHeaders(r);
- return r;
- }
- // MCP Streamable HTTP: POST JSON-RPC
- if ("POST".equals(method) && ("/".equals(uri) || "/mcp".equals(uri) || uri.startsWith("/mcp"))) {
- Response r = handleJsonRpc(session);
- addCommonHeaders(r);
- r.addHeader("Mcp-Session-Id", sessionId);
- return r;
- }
- // DELETE /mcp terminates session
- if ("DELETE".equals(method) && ("/mcp".equals(uri))) {
- sessionId = java.util.UUID.randomUUID().toString();
- Response r = newFixedLengthResponse(Response.Status.OK, "application/json", "{}");
- addCommonHeaders(r);
- return r;
- }
- // GET /mcp — Streamable HTTP server check
- if ("GET".equals(method) && ("/mcp".equals(uri) || "/".equals(uri))) {
- Response r = newFixedLengthResponse(Response.Status.OK, "application/json",
- "{\"status\":\"ok\"}");
- addCommonHeaders(r);
- return r;
- }
- // Health check
- if ("GET".equals(method) && "/health".equals(uri)) {
- Response r = newFixedLengthResponse(Response.Status.OK, "application/json",
- "{\"status\":\"ok\",\"initialized\":" + initialized + "}");
- addCommonHeaders(r);
- return r;
- }
- return newFixedLengthResponse(Response.Status.NOT_FOUND, "application/json",
- "{\"error\":\"not found\"}");
- }
- private void addCommonHeaders(Response r) {
- r.addHeader("Access-Control-Allow-Origin", "*");
- r.addHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, OPTIONS");
- r.addHeader("Access-Control-Allow-Headers", "Content-Type, Accept, Mcp-Session-Id");
- r.addHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
- }
- // ── JSON-RPC dispatch ─────────────────────────────────────────
- private Response handleJsonRpc(IHTTPSession session) {
- String body;
- try {
- body = readBody(session);
- } catch (IOException e) {
- return jsonError(null, -32700, "Parse error: cannot read body");
- }
- if (body == null || body.trim().isEmpty()) {
- return jsonError(null, -32700, "Parse error: empty body");
- }
- JSONObject request;
- try {
- request = new JSONObject(body);
- } catch (JSONException e) {
- return jsonError(null, -32700, "Parse error: invalid JSON");
- }
- String method = null;
- Object id = null;
- try {
- String jsonrpc = request.optString("jsonrpc");
- if (!"2.0".equals(jsonrpc)) {
- return jsonError(null, -32600, "Invalid Request: jsonrpc must be 2.0");
- }
- method = request.optString("method");
- id = request.isNull("id") ? null : request.get("id");
- if (method.isEmpty()) {
- return jsonError(id, -32600, "Invalid Request: missing method");
- }
- // Notification (no id) — no response expected
- if (id == null) {
- handleNotification(method, request.optJSONObject("params"));
- return newFixedLengthResponse(Response.Status.NO_CONTENT, "application/json", "");
- }
- // Regular request
- JSONObject result = dispatch(method, request.optJSONObject("params"));
- return jsonResponse(id, result);
- } catch (JSONException e) {
- Log.e(TAG, "JSON-RPC error", e);
- return jsonError(id, -32603, "Internal error: " + e.getMessage());
- }
- }
- private JSONObject dispatch(String method, JSONObject params) throws JSONException {
- if (params == null) params = new JSONObject();
- switch (method) {
- case "initialize": return handleInitialize(params);
- case "tools/list": return handleToolsList();
- case "tools/call": return handleToolsCall(params);
- case "resources/list": return handleResourcesList();
- case "resources/read": return handleResourcesRead(params);
- default:
- throw new JSONException("Method not found: " + method);
- }
- }
- private void handleNotification(String method, JSONObject params) {
- if ("notifications/initialized".equals(method)) {
- initialized = true;
- Log.i(TAG, "MCP client initialized");
- }
- }
- // ── initialize ────────────────────────────────────────────────
- private JSONObject handleInitialize(JSONObject params) throws JSONException {
- JSONObject result = new JSONObject();
- result.put("protocolVersion", PROTOCOL_VERSION);
- JSONObject capabilities = new JSONObject();
- capabilities.put("tools", new JSONObject());
- capabilities.put("resources", new JSONObject());
- result.put("capabilities", capabilities);
- JSONObject serverInfo = new JSONObject();
- serverInfo.put("name", SERVER_NAME);
- serverInfo.put("version", SERVER_VERSION);
- result.put("serverInfo", serverInfo);
- Log.i(TAG, "MCP initialize handshake");
- return result;
- }
- // ── tools/list ────────────────────────────────────────────────
- private JSONObject handleToolsList() throws JSONException {
- JSONArray tools = new JSONArray();
- // --- Core computation ---
- tools.put(tool("evaluate",
- "Evaluate a Symja mathematical expression. Supports calculus, algebra, " +
- "linear algebra, number theory, special functions, transforms, and more. " +
- "Uses Wolfram/Mathematica-like syntax with square brackets for function calls, " +
- "e.g. Integrate[x^2, x], Solve[x^2-5*x+6==0, x], Det[{{1,2},{3,4}}].",
- inputSchema(
- props().put("expression", stringProp("The Symja expression to evaluate")),
- "expression")));
- tools.put(tool("differentiate",
- "Differentiate an expression with respect to a variable.",
- inputSchema(
- props()
- .put("expression", stringProp("Expression to differentiate, e.g. x^3*Sin[x]"))
- .put("variable", stringProp("Variable of differentiation, e.g. x")),
- "expression", "variable")));
- tools.put(tool("integrate",
- "Integrate an expression. Omit bounds for indefinite integration; " +
- "provide both lower and upper for definite integration.",
- inputSchema(
- props()
- .put("expression", stringProp("Expression to integrate"))
- .put("variable", stringProp("Variable of integration"))
- .put("lower", stringProp("Lower bound (optional, omit for indefinite integral)"))
- .put("upper", stringProp("Upper bound (optional, omit for indefinite integral)")),
- "expression", "variable")));
- tools.put(tool("solve",
- "Solve an equation or system of equations symbolically.",
- inputSchema(
- props()
- .put("equation", stringProp("Equation to solve, e.g. x^2-5*x+6==0"))
- .put("variable", stringProp("Variable to solve for, e.g. x")),
- "equation", "variable")));
- tools.put(tool("simplify",
- "Simplify a mathematical expression algebraically.",
- inputSchema(
- props().put("expression", stringProp("Expression to simplify, e.g. Sin[x]^2+Cos[x]^2")),
- "expression")));
- tools.put(tool("numeric",
- "Evaluate an expression numerically with specified precision.",
- inputSchema(
- props()
- .put("expression", stringProp("Expression to compute numerically, e.g. Pi"))
- .put("precision", integerProp("Number of significant digits (default: 16)")),
- "expression")));
- tools.put(tool("to_latex",
- "Convert a mathematical expression to LaTeX format string.",
- inputSchema(
- props().put("expression", stringProp("Expression to convert to LaTeX")),
- "expression")));
- tools.put(tool("session_variables",
- "List the currently defined global variables and their values.",
- inputSchema(props())));
- // --- Plotting ---
- tools.put(tool("plot",
- "Plot a function f[x] over a range. Returns BOTH: (1) a text block with " +
- "structured plot data as JSON {type:'plot', elements:[{type:'line'|'point', " +
- "color:'#RRGGBB', points:[[x,y],...]}], options:{...}}, and (2) an image block " +
- "with the rendered curve as a PNG, so you can see the plot directly. " +
- "Use Symja math syntax.",
- inputSchema(
- props()
- .put("function", stringProp("Function expression, e.g. Sin[x] or x^2"))
- .put("variable", stringProp("Variable name (usually x)"))
- .put("x_min", stringProp("Lower bound"))
- .put("x_max", stringProp("Upper bound")),
- "function", "variable", "x_min", "x_max")));
- tools.put(tool("parametric_plot",
- "Plot a parametric curve (x(t), y(t)). Returns structured plot data as JSON " +
- "plus a rendered PNG image (same shape as the plot tool).",
- inputSchema(
- props()
- .put("x_func", stringProp("x(t) expression, e.g. Sin[t]"))
- .put("y_func", stringProp("y(t) expression, e.g. Cos[t]"))
- .put("t_var", stringProp("Parameter variable (usually t)"))
- .put("t_min", stringProp("Lower bound for t"))
- .put("t_max", stringProp("Upper bound for t")),
- "x_func", "y_func", "t_var", "t_min", "t_max")));
- tools.put(tool("list_plot",
- "Plot a list of points as a scatter plot. Returns structured plot data as JSON " +
- "plus a rendered PNG image (elements are of type 'point').",
- inputSchema(
- props()
- .put("points", stringProp(
- "Point list in Symja format: {{x1,y1},{x2,y2},...}")),
- "points")));
- JSONObject result = new JSONObject();
- result.put("tools", tools);
- return result;
- }
- // ── tools/call ────────────────────────────────────────────────
- /** 这三个工具的返回值是绘图 JSON,可以再渲染成 PNG。 */
- private static boolean isPlotTool(String toolName) {
- return "plot".equals(toolName)
- || "parametric_plot".equals(toolName)
- || "list_plot".equals(toolName);
- }
- private JSONObject handleToolsCall(JSONObject params) throws JSONException {
- String name = params.getString("name");
- JSONObject arguments = params.optJSONObject("arguments");
- if (arguments == null) arguments = new JSONObject();
- String textResult;
- switch (name) {
- case "evaluate":
- textResult = callEvaluate(arguments.getString("expression"));
- break;
- case "differentiate":
- textResult = callDifferentiate(
- arguments.getString("expression"),
- arguments.getString("variable"));
- break;
- case "integrate":
- textResult = callIntegrate(
- arguments.getString("expression"),
- arguments.getString("variable"),
- arguments.optString("lower", null),
- arguments.optString("upper", null));
- break;
- case "solve":
- textResult = callSolve(
- arguments.getString("equation"),
- arguments.getString("variable"));
- break;
- case "simplify":
- textResult = callSimplify(arguments.getString("expression"));
- break;
- case "numeric":
- textResult = callNumeric(
- arguments.getString("expression"),
- arguments.optInt("precision", 16));
- break;
- case "to_latex":
- textResult = callToLatex(arguments.getString("expression"));
- break;
- case "session_variables":
- textResult = callSessionVariables();
- break;
- case "plot":
- textResult = callPlot(
- arguments.getString("function"),
- arguments.getString("variable"),
- arguments.getString("x_min"),
- arguments.getString("x_max"));
- break;
- case "parametric_plot":
- textResult = callParametricPlot(
- arguments.getString("x_func"),
- arguments.getString("y_func"),
- arguments.getString("t_var"),
- arguments.getString("t_min"),
- arguments.getString("t_max"));
- break;
- case "list_plot":
- textResult = callListPlot(arguments.getString("points"));
- break;
- default:
- throw new JSONException("Unknown tool: " + name);
- }
- JSONArray content = new JSONArray();
- JSONObject item = new JSONObject();
- item.put("type", "text");
- item.put("text", textResult);
- content.put(item);
- // 绘图工具额外附一张 PNG —— 客户端既能拿坐标自己处理,也能直接"看到"图。
- // 渲染失败就只回坐标,不影响调用成功。
- if (isPlotTool(name) && textResult.startsWith("{")) {
- String png = PlotImage.renderPngBase64(textResult, 640, 400);
- if (png != null) {
- JSONObject img = new JSONObject();
- img.put("type", "image");
- img.put("data", png);
- img.put("mimeType", "image/png");
- content.put(img);
- }
- }
- JSONObject result = new JSONObject();
- result.put("content", content);
- // MCP spec: isError at result top level
- if (textResult.startsWith("Error:") || textResult.startsWith("错误:")) {
- result.put("isError", true);
- }
- return result;
- }
- // ── Tool implementations ──────────────────────────────────────
- /**
- * 求值。全部计算都委托给 SymjaBridge 的单线程 worker(Symja 非线程安全,
- * 见 SymjaBridge 类注释)。evaluateSync 不更新 % (lastOutput),所以 MCP
- * 调用不会顶掉界面上用户按 ANS 拿到的东西。
- */
- private String callEvaluate(String expression) {
- String result = SymjaBridge.INSTANCE.evaluateSync(expression, EVAL_TIMEOUT_MS);
- synchronized (evalHistory) {
- if (evalHistory.size() >= 100) evalHistory.remove(0);
- evalHistory.add(expression + " → " + result);
- }
- return result;
- }
- private String callDifferentiate(String expression, String variable) {
- return SymjaBridge.INSTANCE.evaluateSync(
- "D[" + expression + ", " + variable + "]", EVAL_TIMEOUT_MS);
- }
- private String callIntegrate(String expression, String variable, String lower, String upper) {
- String expr;
- if (lower != null && !lower.isEmpty() && upper != null && !upper.isEmpty()) {
- expr = "Integrate[" + expression + ", {" + variable + ", " + lower + ", " + upper + "}]";
- } else {
- expr = "Integrate[" + expression + ", " + variable + "]";
- }
- return SymjaBridge.INSTANCE.evaluateSync(expr, EVAL_TIMEOUT_MS);
- }
- private String callSolve(String equation, String variable) {
- return SymjaBridge.INSTANCE.evaluateSync(
- "Solve[" + equation + ", " + variable + "]", EVAL_TIMEOUT_MS);
- }
- private String callSimplify(String expression) {
- return SymjaBridge.INSTANCE.evaluateSync(
- "Simplify[" + expression + "]", EVAL_TIMEOUT_MS);
- }
- private String callNumeric(String expression, int precision) {
- return SymjaBridge.INSTANCE.evaluateSync(
- "N[" + expression + ", " + precision + "]", EVAL_TIMEOUT_MS);
- }
- /**
- * 转 LaTeX。
- *
- * 保留 ##TEXFORM## 前缀:控制台据此识别并用 KaTeX 渲染,这是旧实现约定的格式。
- * 注意 TeXForm 若失败,结果会以 "Error: " 开头,前缀会盖住它,isError 因而
- * 检测不到——这与旧行为完全一致,不做改动以免破坏既有前端逻辑。
- */
- private String callToLatex(String expression) {
- String result = SymjaBridge.INSTANCE.evaluateSync(
- "TeXForm[" + expression + "]", EVAL_TIMEOUT_MS);
- return "##TEXFORM##" + result;
- }
- /** 当前全局变量,SymjaBridge 输出 "名字 = 值" 文本,一行一个。 */
- private String callSessionVariables() {
- return SymjaBridge.INSTANCE.variablesSync();
- }
- private String callPlot(String function, String variable, String xMin, String xMax) {
- String expr = "Plot[" + function + ", {" + variable + ", " + xMin + ", " + xMax + "}]";
- // plotSync 会把 Symja 的 Graphics 表达式树转成 {type:"plot", elements:[...]} JSON
- return SymjaBridge.INSTANCE.plotSync(expr, EVAL_TIMEOUT_MS);
- }
- private String callParametricPlot(String xFunc, String yFunc, String tVar, String tMin, String tMax) {
- String expr = "ParametricPlot[{" + xFunc + ", " + yFunc + "}, {" + tVar + ", " + tMin + ", " + tMax + "}]";
- return SymjaBridge.INSTANCE.plotSync(expr, EVAL_TIMEOUT_MS);
- }
- private String callListPlot(String points) {
- return SymjaBridge.INSTANCE.plotSync(
- "ListPlot[" + points + "]", EVAL_TIMEOUT_MS);
- }
- // ── resources/list ────────────────────────────────────────────
- private JSONObject handleResourcesList() throws JSONException {
- JSONArray resources = new JSONArray();
- resources.put(resource("variables://current",
- "Defined Variables",
- "text/plain",
- "Currently defined variables and their values in the Symja session"));
- resources.put(resource("history://recent",
- "Recent MCP Evaluations",
- "application/json",
- "Recent expressions evaluated via MCP and their results"));
- JSONObject result = new JSONObject();
- result.put("resources", resources);
- return result;
- }
- // ── resources/read ────────────────────────────────────────────
- private JSONObject handleResourcesRead(JSONObject params) throws JSONException {
- String uri = params.getString("uri");
- JSONArray contents = new JSONArray();
- switch (uri) {
- case "variables://current": {
- String vars = SymjaBridge.INSTANCE.variablesSync();
- contents.put(resourceContent(uri, "text/plain", vars));
- break;
- }
- case "history://recent": {
- JSONArray historyJson = new JSONArray();
- for (String entry : evalHistory) {
- historyJson.put(entry);
- }
- contents.put(resourceContent(uri, "application/json", historyJson.toString(2)));
- break;
- }
- default:
- throw new JSONException("Unknown resource URI: " + uri);
- }
- JSONObject result = new JSONObject();
- result.put("contents", contents);
- return result;
- }
- // ── HTTP helpers ──────────────────────────────────────────────
- private String readBody(IHTTPSession session) throws IOException {
- String contentLength = session.getHeaders().get("content-length");
- if (contentLength == null) return null;
- int len = Integer.parseInt(contentLength);
- if (len == 0) return null;
- byte[] buf = new byte[len];
- int total = 0;
- while (total < len) {
- int r = session.getInputStream().read(buf, total, len - total);
- if (r == -1) break;
- total += r;
- }
- return new String(buf, 0, total, "UTF-8");
- }
- // ── JSON builders ─────────────────────────────────────────────
- /** Create a complete tool object with name, description, and inputSchema. */
- private JSONObject tool(String name, String description, JSONObject inputSchema) throws JSONException {
- JSONObject t = new JSONObject();
- t.put("name", name);
- t.put("description", description);
- t.put("inputSchema", inputSchema);
- return t;
- }
- /** Build inputSchema: {type:object, properties:{...}, required:[...]} */
- private JSONObject inputSchema(JSONObject properties, String... required) throws JSONException {
- JSONObject schema = new JSONObject();
- schema.put("type", "object");
- schema.put("properties", properties);
- JSONArray req = new JSONArray();
- for (String r : required) req.put(r);
- schema.put("required", req);
- return schema;
- }
- /** Start building a properties object. Call .put() on the result. */
- private JSONObject props() {
- return new JSONObject();
- }
- private JSONObject stringProp(String description) throws JSONException {
- JSONObject p = new JSONObject();
- p.put("type", "string");
- p.put("description", description);
- return p;
- }
- private JSONObject integerProp(String description) throws JSONException {
- JSONObject p = new JSONObject();
- p.put("type", "integer");
- p.put("description", description);
- return p;
- }
- private JSONObject resource(String uri, String name, String mimeType, String description) throws JSONException {
- JSONObject r = new JSONObject();
- r.put("uri", uri);
- r.put("name", name);
- r.put("mimeType", mimeType);
- r.put("description", description);
- return r;
- }
- private JSONObject resourceContent(String uri, String mimeType, String text) throws JSONException {
- JSONObject c = new JSONObject();
- c.put("uri", uri);
- c.put("mimeType", mimeType);
- c.put("text", text);
- return c;
- }
- // ── Response builders ─────────────────────────────────────────
- private Response jsonResponse(Object id, JSONObject result) {
- JSONObject resp = new JSONObject();
- try {
- resp.put("jsonrpc", "2.0");
- resp.put("id", id);
- resp.put("result", result);
- } catch (JSONException ignored) {}
- return newFixedLengthResponse(Response.Status.OK, "application/json", resp.toString());
- }
- private Response jsonError(Object id, int code, String message) {
- JSONObject resp = new JSONObject();
- try {
- resp.put("jsonrpc", "2.0");
- resp.put("id", (id != null) ? id : JSONObject.NULL);
- JSONObject err = new JSONObject();
- err.put("code", code);
- err.put("message", message);
- resp.put("error", err);
- } catch (JSONException ignored) {}
- Response.Status status = (code == -32700)
- ? Response.Status.BAD_REQUEST
- : Response.Status.INTERNAL_ERROR;
- return newFixedLengthResponse(status, "application/json", resp.toString());
- }
- /** Gracefully stop the server. */
- public void stopServer() {
- initialized = false;
- stop();
- }
- }
|