Просмотр исходного кода

feat: RikkaHub MCP compatibility + UI cleanup

- MCP: Streamable HTTP + SSE dual transport
  - GET /mcp with Accept: text/event-stream returns SSE endpoint event
  - DELETE /mcp terminates session
  - Mcp-Session-Id header in all responses
  - CORS extended for MCP headers
- UI: removed MCP FAB button (settings panel retained)
- New 'mcp' console command shows server address and config
- Fixed: default plugins auto-install on every launch
- Plugin panel: added restore defaults button
- Updated MCP_SERVER.md with RikkaHub config example

Co-Authored-By: Claude <noreply@anthropic.com>
nonvioce 3 месяцев назад
Родитель
Сommit
14f26cbc33

+ 23 - 4
MCP_SERVER.md

@@ -4,8 +4,7 @@ IJS 内置 MCP (Model Context Protocol) 服务器,可将 Symja 符号计算能
 
 ## 快速开始
 
-1. 打开应用,MCP 服务器默认自动启动(端口 8734)
-2. 在 Claude Desktop 配置中添加:
+### Claude Desktop
 
 ```json
 {
@@ -17,13 +16,33 @@ IJS 内置 MCP (Model Context Protocol) 服务器,可将 Symja 符号计算能
 }
 ```
 
-3. 如果 Claude Desktop 和 IJS 不在同一台设备,先通过 adb 转发:
+### RikkaHub(Rikka AI)
+
+RikkaHub 使用 Streamable HTTP 传输协议,直接配置即可:
+
+```json
+{
+  "type": "sse",
+  "url": "http://127.0.0.1:8734/mcp",
+  "commonOptions": {
+    "name": "IJS - Symja 符号计算",
+    "enable": true,
+    "headers": []
+  }
+}
+```
+
+> 支持 SSE 和 Streamable HTTP 双传输协议,自动检测客户端类型。
+
+### 网络配置
+
+如果客户端和 IJS 不在同一台设备,先通过 adb 转发:
 
 ```bash
 adb forward tcp:8734 tcp:8734
 ```
 
-4. 顶部状态灯变绿表示服务器运行中,灰色表示未启动
+若需局域网访问,在路由器上对 8734 端口做端口转发。顶部状态灯变绿表示服务器运行中。
 
 ## 工具列表
 

+ 3 - 1
app/src/main/assets/console.html

@@ -114,7 +114,9 @@
         </div>
         
         <!-- 插件列表 -->
-        <div id="pluginList" class="plugin-list tab-content active" data-content="list"></div>
+        <div id="pluginList" class="plugin-list tab-content active" data-content="list">
+            <button id="restorePluginsBtn" class="action-btn secondary" style="margin-top:12px;width:100%">🔄 恢复默认插件</button>
+        </div>
         
         <!-- 开发文档 -->
         <div class="plugin-docs tab-content" data-content="docs">

+ 29 - 0
app/src/main/assets/console.js

@@ -73,6 +73,19 @@ class SymjaConsole {
             this.hideHelp();
         });
 
+        // 恢复默认插件
+        document.getElementById('restorePluginsBtn')?.addEventListener('click', () => {
+            try {
+                const result = Android.restoreDefaultPlugins();
+                this.loadPlugins();
+                this.refreshPluginList();
+                this.addOutput(result, 'help');
+                this.scrollToBottom();
+            } catch(e) {
+                this.addOutput('❌ 恢复失败: ' + e.message, 'error');
+            }
+        });
+
         // 插件面板事件
         document.getElementById('closePluginPanel')?.addEventListener('click', () => {
             this.hidePluginPanel();
@@ -610,6 +623,10 @@ class SymjaConsole {
             this.showPluginPanel();
             return true;
         }
+        if (lower === 'mcp') {
+            this.showMcpAddress();
+            return true;
+        }
         if (lower === 'feedback') {
             this.showFeedback();
             return true;
@@ -1102,6 +1119,18 @@ class SymjaConsole {
         this.scrollToBottom();
     }
 
+    showMcpAddress() {
+        try {
+            const running = typeof Android !== 'undefined' && Android.isMcpRunning && Android.isMcpRunning();
+            const port = '8734';
+            const status = running ? '✅ 运行中' : '⭕ 未启动';
+            this.addOutput(`🌐 MCP 服务器状态: ${status}\n   地址: http://127.0.0.1:${port}/mcp\n   健康检查: http://127.0.0.1:${port}/health\n\n   Claude Desktop 配置:\n   {"mcpServers":{"ijs":{"url":"http://127.0.0.1:${port}/mcp"}}}\n\n   adb 端口转发:\n   adb forward tcp:${port} tcp:${port}`, 'help');
+        } catch(e) {
+            this.addOutput('🌐 MCP 服务器\n   地址: http://127.0.0.1:8734/mcp', 'help');
+        }
+        this.scrollToBottom();
+    }
+
     showFeedback() {
         const email = 'nonvioce@qq.com';
         const feedbackId = 'feedback-' + Date.now();

+ 5 - 43
app/src/main/java/com/nonvioce/ijs/MainActivity.java

@@ -8,7 +8,6 @@ import android.webkit.WebView;
 import android.widget.Toast;
 import androidx.appcompat.app.AppCompatActivity;
 import androidx.core.content.ContextCompat;
-import com.google.android.material.floatingactionbutton.FloatingActionButton;
 import com.nonvioce.ijs.databinding.ActivityMainBinding;
 
 public class MainActivity extends AppCompatActivity {
@@ -69,9 +68,6 @@ public class MainActivity extends AppCompatActivity {
         binding.webView.addJavascriptInterface(new WebAppInterface(), "Android");
         binding.webView.loadUrl("file:///android_asset/console.html");
 
-        // MCP server toggle
-        setupMcpToggle();
-
         // Auto-start MCP server if enabled in settings (default: on)
         android.content.SharedPreferences prefs = getPreferences(MODE_PRIVATE);
         if (prefs.getBoolean("mcp_enabled", true) && !McpService.isRunning()) {
@@ -88,44 +84,6 @@ public class MainActivity extends AppCompatActivity {
         }
     }
 
-    // ── MCP toggle ────────────────────────────────────────────────
-
-    private void setupMcpToggle() {
-        FloatingActionButton fab = binding.mcpToggleFab;
-        updateMcpFabIcon(fab);
-
-        fab.setOnClickListener(v -> {
-            if (McpService.isRunning()) {
-                stopMcpServer();
-            } else {
-                startMcpServer(DEFAULT_MCP_PORT);
-            }
-            updateMcpFabIcon(fab);
-        });
-
-        fab.setOnLongClickListener(v -> {
-            String msg = McpService.isRunning()
-                    ? getString(R.string.mcp_started, McpService.getPort())
-                    : getString(R.string.mcp_stopped);
-            Toast.makeText(this, msg, Toast.LENGTH_SHORT).show();
-            return true;
-        });
-    }
-
-    private void updateMcpFabIcon(FloatingActionButton fab) {
-        if (McpService.isRunning()) {
-            fab.setBackgroundTintList(
-                    android.content.res.ColorStateList.valueOf(
-                            getColor(R.color.mcp_fab_on)));
-            fab.setImageResource(android.R.drawable.ic_menu_share);
-        } else {
-            fab.setBackgroundTintList(
-                    android.content.res.ColorStateList.valueOf(
-                            getColor(R.color.mcp_fab_off)));
-            fab.setImageResource(android.R.drawable.ic_menu_share);
-        }
-    }
-
     private void startMcpServer(int port) {
         Intent intent = new Intent(this, McpService.class);
         intent.setAction(McpService.ACTION_START);
@@ -174,6 +132,11 @@ public class MainActivity extends AppCompatActivity {
             return pluginManager.savePlugin(pluginJson);
         }
 
+        @JavascriptInterface
+        public String restoreDefaultPlugins() {
+            return pluginManager.restoreDefaultPlugins();
+        }
+
         @JavascriptInterface
         public String deletePlugin(String pluginId) {
             return pluginManager.deletePlugin(pluginId);
@@ -482,7 +445,6 @@ public class MainActivity extends AppCompatActivity {
                 } else if (!enabled && McpService.isRunning()) {
                     stopMcpServer();
                 }
-                updateMcpFabIcon(binding.mcpToggleFab);
             });
         }
 

+ 35 - 8
app/src/main/java/com/nonvioce/ijs/McpServer.java

@@ -40,6 +40,8 @@ public class McpServer extends NanoHTTPD {
         this.pluginManager = pluginManager;
     }
 
+    private String sessionId = java.util.UUID.randomUUID().toString();
+
     // ── HTTP routing ──────────────────────────────────────────────
 
     @Override
@@ -50,14 +52,31 @@ public class McpServer extends NanoHTTPD {
         // CORS preflight
         if ("OPTIONS".equals(method)) {
             Response r = newFixedLengthResponse(Response.Status.OK, "text/plain", "");
-            addCors(r);
+            addCommonHeaders(r);
             return r;
         }
 
-        // MCP JSON-RPC endpoint
-        if ("POST".equals(method) && ("/".equals(uri) || "/mcp".equals(uri))) {
+        // MCP Streamable HTTP: POST JSON-RPC
+        if ("POST".equals(method) && ("/".equals(uri) || "/mcp".equals(uri) || uri.startsWith("/mcp"))) {
             Response r = handleJsonRpc(session);
-            addCors(r);
+            addCommonHeaders(r);
+            r.addHeader("Mcp-Session-Id", sessionId);
+            return r;
+        }
+
+        // MCP SSE endpoint (for server→client notifications)
+        if ("GET".equals(method) && "/mcp".equals(uri)) {
+            String accept = session.getHeaders().getOrDefault("accept", "").toLowerCase();
+            if (accept.contains("text/event-stream")) {
+                return handleSse();
+            }
+        }
+
+        // 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;
         }
 
@@ -65,17 +84,25 @@ public class McpServer extends NanoHTTPD {
         if ("GET".equals(method) && "/health".equals(uri)) {
             Response r = newFixedLengthResponse(Response.Status.OK, "application/json",
                     "{\"status\":\"ok\",\"initialized\":" + initialized + "}");
-            addCors(r);
+            addCommonHeaders(r);
             return r;
         }
 
         return newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not Found");
     }
 
-    private void addCors(Response r) {
+    private void addCommonHeaders(Response r) {
         r.addHeader("Access-Control-Allow-Origin", "*");
-        r.addHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
-        r.addHeader("Access-Control-Allow-Headers", "Content-Type");
+        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");
+    }
+
+    /** SSE 端点:发送 endpoint 事件并保持连接 */
+    private Response handleSse() {
+        return newFixedLengthResponse(Response.Status.OK, "text/event-stream",
+            "event: endpoint\r\n" +
+            "data: /mcp?sessionId=" + sessionId + "\r\n\r\n");
     }
 
     // ── JSON-RPC dispatch ─────────────────────────────────────────

+ 47 - 13
app/src/main/java/com/nonvioce/ijs/PluginManager.java

@@ -60,13 +60,14 @@ public class PluginManager {
         this.pluginsDir = new File(context.getExternalFilesDir(null), "plugins");
         if (!pluginsDir.exists()) {
             pluginsDir.mkdirs();
-            createSamplePlugins();
         }
         loadPlugins();
+        // 每次启动时补装缺失的默认插件
+        installMissingDefaults();
     }
 
-    private void createSamplePlugins() {
-        // 从 assets/plugins/ 复制默认插件
+    /** 安装缺失的默认插件(每次启动调用) */
+    private void installMissingDefaults() {
         String[] defaultPlugins;
         try {
             defaultPlugins = context.getAssets().list("plugins");
@@ -75,23 +76,56 @@ public class PluginManager {
         }
         if (defaultPlugins == null) return;
 
+        boolean anyInstalled = false;
         for (String filename : defaultPlugins) {
             if (!filename.endsWith(".json")) continue;
-            try {
-                java.io.InputStream is = context.getAssets().open("plugins/" + filename);
-                java.io.File outFile = new java.io.File(pluginsDir, filename);
-                java.io.FileOutputStream os = new java.io.FileOutputStream(outFile);
-                byte[] buf = new byte[4096];
-                int len;
-                while ((len = is.read(buf)) > 0) {
-                    os.write(buf, 0, len);
+            java.io.File target = new java.io.File(pluginsDir, filename);
+            if (!target.exists()) {
+                try {
+                    copyAsset("plugins/" + filename, target);
+                    anyInstalled = true;
+                } catch (Exception e) {
+                    e.printStackTrace();
                 }
-                os.close();
-                is.close();
+            }
+        }
+        if (anyInstalled) loadPlugins();
+    }
+
+    /** 恢复所有默认插件(覆盖已有) */
+    public String restoreDefaultPlugins() {
+        String[] defaultPlugins;
+        try {
+            defaultPlugins = context.getAssets().list("plugins");
+        } catch (Exception e) {
+            return "❌ 无法读取默认插件";
+        }
+        if (defaultPlugins == null) return "❌ 无默认插件";
+
+        int count = 0;
+        for (String filename : defaultPlugins) {
+            if (!filename.endsWith(".json")) continue;
+            try {
+                copyAsset("plugins/" + filename, new java.io.File(pluginsDir, filename));
+                count++;
             } catch (Exception e) {
                 e.printStackTrace();
             }
         }
+        loadPlugins();
+        return "✅ 已恢复 " + count + " 个默认插件";
+    }
+
+    private void copyAsset(String assetPath, java.io.File outFile) throws Exception {
+        java.io.InputStream is = context.getAssets().open(assetPath);
+        java.io.FileOutputStream os = new java.io.FileOutputStream(outFile);
+        byte[] buf = new byte[4096];
+        int len;
+        while ((len = is.read(buf)) > 0) {
+            os.write(buf, 0, len);
+        }
+        os.close();
+        is.close();
     }
 
     private void writeFile(File file, String content) throws Exception {

+ 0 - 13
app/src/main/res/layout/activity_main.xml

@@ -15,17 +15,4 @@
         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintTop_toTopOf="parent" />
 
-    <!-- MCP server toggle button -->
-    <com.google.android.material.floatingactionbutton.FloatingActionButton
-        android:id="@+id/mcpToggleFab"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_margin="16dp"
-        android:contentDescription="@string/mcp_toggle_description"
-        android:src="@android:drawable/ic_menu_share"
-        app:backgroundTint="@color/mcp_fab_off"
-        app:fabSize="mini"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent" />
-
 </androidx.constraintlayout.widget.ConstraintLayout>