@jonathanleelx/meddle 0.5.0-beta.23 → 0.5.0-beta.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CONFIG_STRUCTURE.md +1 -0
  2. package/README.md +16 -0
  3. package/bin/commands/route/active.js +14 -1
  4. package/bin/commands/route/add.js +112 -103
  5. package/bin/commands/route/delete.js +73 -49
  6. package/bin/commands/route/index.js +20 -2
  7. package/bin/commands/route/list.js +2 -2
  8. package/bin/commands/route/show.js +10 -1
  9. package/bin/commands/route/update.js +134 -101
  10. package/bin/index.js +3 -1
  11. package/bin/lib/api-client.js +14 -2
  12. package/bin/lib/file-access.js +39 -1
  13. package/bin/lib/route-write-args.js +150 -0
  14. package/bin/lib/rule-file-edit.js +263 -0
  15. package/bin/lib/ruleset-readonly.js +61 -0
  16. package/dist/core/mock-handler.d.ts.map +1 -1
  17. package/dist/core/mock-handler.js +34 -5
  18. package/dist/core/mock-handler.js.map +1 -1
  19. package/dist/core/rule-file-edit.d.ts +81 -0
  20. package/dist/core/rule-file-edit.d.ts.map +1 -0
  21. package/dist/core/rule-file-edit.js +243 -0
  22. package/dist/core/rule-file-edit.js.map +1 -0
  23. package/dist/core/static-server.d.ts.map +1 -1
  24. package/dist/core/static-server.js +15 -13
  25. package/dist/core/static-server.js.map +1 -1
  26. package/dist/server/agent/tools.d.ts.map +1 -1
  27. package/dist/server/agent/tools.js +20 -18
  28. package/dist/server/agent/tools.js.map +1 -1
  29. package/dist/server/app-info.d.ts +14 -0
  30. package/dist/server/app-info.d.ts.map +1 -1
  31. package/dist/server/app-info.js +33 -1
  32. package/dist/server/app-info.js.map +1 -1
  33. package/dist/server/config.d.ts.map +1 -1
  34. package/dist/server/config.js +51 -9
  35. package/dist/server/config.js.map +1 -1
  36. package/dist/server/index.d.ts.map +1 -1
  37. package/dist/server/index.js +1 -0
  38. package/dist/server/index.js.map +1 -1
  39. package/dist/server/rule-files.d.ts +23 -0
  40. package/dist/server/rule-files.d.ts.map +1 -1
  41. package/dist/server/rule-files.js +169 -81
  42. package/dist/server/rule-files.js.map +1 -1
  43. package/dist/server/ruleset-lock.d.ts +62 -0
  44. package/dist/server/ruleset-lock.d.ts.map +1 -0
  45. package/dist/server/ruleset-lock.js +234 -0
  46. package/dist/server/ruleset-lock.js.map +1 -0
  47. package/mcp-server.js +87 -88
  48. package/package.json +1 -1
  49. package/web/dist/assets/{detail-panel-D4JSaFH3.js → detail-panel-CyOc7gn8.js} +3 -3
  50. package/web/dist/assets/dist-BQN0rzUS.js +1 -0
  51. package/web/dist/assets/{global-panel-shell-DXvcxmbd.js → global-panel-shell-jXQTb9nw.js} +3 -3
  52. package/web/dist/assets/{health-panel-Bo0eD8_R.js → health-panel-CSV7OqBv.js} +1 -1
  53. package/web/dist/assets/index-C6dPxxT3.css +2 -0
  54. package/web/dist/assets/index-CSXxaYkG.js +21 -0
  55. package/web/dist/assets/{mobile-proxy-panel-FQwFQFmS.js → mobile-proxy-panel-a09S-R79.js} +2 -2
  56. package/web/dist/assets/mock-config-D_E5YOE_.js +1 -0
  57. package/web/dist/assets/{mock-editor-panel-kx0bzGv4.js → mock-editor-panel-t4TDXeaR.js} +3 -3
  58. package/web/dist/assets/{mocks-file-missing-note-BvL7-uzS.js → mocks-file-missing-note-Ddy1TdMX.js} +1 -1
  59. package/web/dist/assets/{monaco-editor-BLHN8gCU.js → monaco-editor-GpPLOKA5.js} +2 -2
  60. package/web/dist/assets/pane-bar-zJmE3xI5.js +1 -0
  61. package/web/dist/assets/{pane-split-DfQcgd1v.js → pane-split-CJ1VXJDy.js} +1 -1
  62. package/web/dist/assets/panel-heading-DlFWoYAR.js +1 -0
  63. package/web/dist/assets/plugin-code-editor-CtaamKWJ.js +2 -0
  64. package/web/dist/assets/plugin-config-Dg_FCb-L.js +2 -0
  65. package/web/dist/assets/{plugin-generator-B5FRaaKc.js → plugin-generator-CVREuZs7.js} +2 -2
  66. package/web/dist/assets/plugin-test-dialog-B-iV8ocm.js +7 -0
  67. package/web/dist/assets/route-preview-CLJeg_9v.js +1 -0
  68. package/web/dist/assets/{rule-ai-assistant-panel-BEmXPzli.js → rule-ai-assistant-panel-BvTqm4zV.js} +2 -2
  69. package/web/dist/assets/rule-config-DmNPaKxc.js +15 -0
  70. package/web/dist/assets/{rule-overview-panel-DsNEU8L1.js → rule-overview-panel-CB9b-b0t.js} +1 -1
  71. package/web/dist/assets/settings-panel-DLdeJ8ox.js +1 -0
  72. package/web/dist/assets/toast-9yeTf7d9.js +1 -0
  73. package/web/dist/assets/use-mocks-file-status-BUIUJ4oQ.js +1 -0
  74. package/web/dist/index.html +11 -9
  75. package/web/dist/assets/index-Cbis1y9h.js +0 -18
  76. package/web/dist/assets/index-dKaIrLee.css +0 -2
  77. package/web/dist/assets/mock-config-CL9nXIkx.js +0 -1
  78. package/web/dist/assets/pane-bar-BSzN6D93.js +0 -1
  79. package/web/dist/assets/panel-heading-Dev_soZA.js +0 -1
  80. package/web/dist/assets/plugin-code-editor-uI_-iAXY.js +0 -2
  81. package/web/dist/assets/plugin-config-BpfIX4Uk.js +0 -2
  82. package/web/dist/assets/plugin-test-dialog-Cptd2bs0.js +0 -7
  83. package/web/dist/assets/route-preview-DEc50Y_R.js +0 -1
  84. package/web/dist/assets/rule-config-DQkdF3tr.js +0 -15
  85. package/web/dist/assets/select-CFTzQ8Uy.js +0 -1
  86. package/web/dist/assets/settings-panel-C1v0UmZQ.js +0 -1
  87. /package/web/dist/assets/{alert-DFypnYT1.js → alert-B514Frte.js} +0 -0
  88. /package/web/dist/assets/{babel-DCr4VA1c.js → babel-bwZm0qi3.js} +0 -0
  89. /package/web/dist/assets/{body-diff-view-CCwbvDYx.js → body-diff-view-SfLCLzs9.js} +0 -0
  90. /package/web/dist/assets/{clipboard-VhPXNN5m.js → clipboard-Bt1qY01V.js} +0 -0
  91. /package/web/dist/assets/{dist-Dk9a2sJl.js → dist-DLQKRjuW.js} +0 -0
  92. /package/web/dist/assets/{editor-pane-actions-BGttNlkS.js → editor-pane-actions-_ckwcCII.js} +0 -0
  93. /package/web/dist/assets/{estree-qO_m2Qaw.js → estree-MatbZDwq.js} +0 -0
  94. /package/web/dist/assets/{html-VDGahtyk.js → html-CCrsbFYX.js} +0 -0
  95. /package/web/dist/assets/{postcss-BUm8EKMw.js → postcss-BxN7Gr8n.js} +0 -0
  96. /package/web/dist/assets/{standalone-Bj8SSd8Q.js → standalone-DsnFnsqM.js} +0 -0
  97. /package/web/dist/assets/{syntax-highlight-Dg_CTbXd.js → syntax-highlight-BFgN_C0Y.js} +0 -0
  98. /package/web/dist/assets/{table-B10JkPhg.js → table-DaXD6jJv.js} +0 -0
  99. /package/web/dist/assets/{textarea-BjWF0pk4.js → textarea-9wB8Hsz-.js} +0 -0
  100. /package/web/dist/assets/{wand-sparkles-DDGjzjnX.js → wand-sparkles-Bmt4dDOf.js} +0 -0
  101. /package/web/dist/assets/{wifi-QkTcTrED.js → wifi-Cjj_Br1u.js} +0 -0
  102. /package/web/dist/assets/{zap-CEVoVkAg.js → zap-BuOtogAv.js} +0 -0
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.RulesetReadonlyError = exports.UI_TOKEN_META_NAME = exports.UI_TOKEN_HEADER = exports.READONLY_SETTINGS_KEY = exports.RULESET_READONLY_STATUS = exports.RULESET_READONLY_MESSAGE = exports.RULESET_READONLY = void 0;
37
+ exports.isRulesetReadonlyError = isRulesetReadonlyError;
38
+ exports.rulesetReadonlyBody = rulesetReadonlyBody;
39
+ exports.normalizeReadonlyNames = normalizeReadonlyNames;
40
+ exports.getReadonlyRuleFileNames = getReadonlyRuleFileNames;
41
+ exports.isRuleFileReadonly = isRuleFileReadonly;
42
+ exports.assertRuleFilesWritable = assertRuleFilesWritable;
43
+ exports.assertRuleFileWritable = assertRuleFileWritable;
44
+ exports.setRuleFileReadonly = setRuleFileReadonly;
45
+ exports.getUiToken = getUiToken;
46
+ exports.injectUiToken = injectUiToken;
47
+ exports.isSameOriginRequest = isSameOriginRequest;
48
+ exports.hasValidUiToken = hasValidUiToken;
49
+ const crypto = __importStar(require("crypto"));
50
+ const fs = __importStar(require("fs"));
51
+ const path = __importStar(require("path"));
52
+ /**
53
+ * Issue #137: per-ruleset (rule file) read-only lock.
54
+ *
55
+ * - Stored in settings.json under `readonlyRuleFiles` (file names without
56
+ * `.txt`), never inside the rule file. A missing field means every ruleset
57
+ * is read-write, so old settings need no migration.
58
+ * - Keyed by file name. A rename done outside Meddle makes a new, read-write
59
+ * ruleset; lock entries whose file no longer exists are dropped silently.
60
+ * - Every ruleset write goes through `assertRuleFilesWritable`, which throws
61
+ * `RulesetReadonlyError` (code `RULESET_READONLY`).
62
+ * - Only the web UI can lock / unlock: the request must carry the token the
63
+ * server put into the page (index.html meta / same-origin bootstrap) and pass
64
+ * a same-origin check. This guards against accidents, not against an attacker:
65
+ * anyone with a shell can GET the page, read the token, forge `Origin`, or
66
+ * simply edit the files on disk.
67
+ */
68
+ exports.RULESET_READONLY = 'RULESET_READONLY';
69
+ exports.RULESET_READONLY_MESSAGE = '该规则集已设为只读,只能在 Meddle 界面中解锁';
70
+ /** HTTP status for a write against a locked ruleset (423 Locked). */
71
+ exports.RULESET_READONLY_STATUS = 423;
72
+ exports.READONLY_SETTINGS_KEY = 'readonlyRuleFiles';
73
+ exports.UI_TOKEN_HEADER = 'x-meddle-ui-token';
74
+ exports.UI_TOKEN_META_NAME = 'meddle-ui-token';
75
+ class RulesetReadonlyError extends Error {
76
+ constructor(ruleFile) {
77
+ super(exports.RULESET_READONLY_MESSAGE);
78
+ this.ruleFile = ruleFile;
79
+ this.code = exports.RULESET_READONLY;
80
+ this.status = exports.RULESET_READONLY_STATUS;
81
+ this.name = 'RulesetReadonlyError';
82
+ }
83
+ }
84
+ exports.RulesetReadonlyError = RulesetReadonlyError;
85
+ function isRulesetReadonlyError(err) {
86
+ return err instanceof RulesetReadonlyError;
87
+ }
88
+ function rulesetReadonlyBody(err) {
89
+ return { error: err.message, code: err.code, ruleFile: err.ruleFile };
90
+ }
91
+ function readSettings(ctx) {
92
+ try {
93
+ if (fs.existsSync(ctx.settingsPath)) {
94
+ return JSON.parse(fs.readFileSync(ctx.settingsPath, 'utf8'));
95
+ }
96
+ }
97
+ catch { /* ignore */ }
98
+ return {};
99
+ }
100
+ function writeSettings(ctx, settings) {
101
+ const dir = path.dirname(ctx.settingsPath);
102
+ if (!fs.existsSync(dir))
103
+ fs.mkdirSync(dir, { recursive: true });
104
+ fs.writeFileSync(ctx.settingsPath, JSON.stringify(settings, null, 2), 'utf8');
105
+ }
106
+ /** Normalize the raw settings value; anything that is not a string array counts as empty. */
107
+ function normalizeReadonlyNames(value) {
108
+ if (!Array.isArray(value))
109
+ return [];
110
+ const seen = new Set();
111
+ const names = [];
112
+ for (const item of value) {
113
+ if (typeof item !== 'string' || !item || seen.has(item))
114
+ continue;
115
+ seen.add(item);
116
+ names.push(item);
117
+ }
118
+ return names;
119
+ }
120
+ function ruleFileExists(ctx, name) {
121
+ return fs.existsSync(path.join(ctx.meddleDir, 'route-rules', `${name}.txt`));
122
+ }
123
+ /**
124
+ * Locked rule file names. Entries whose file is gone (deleted or renamed
125
+ * outside Meddle) are removed from settings.json here, silently.
126
+ */
127
+ function getReadonlyRuleFileNames(ctx) {
128
+ const settings = readSettings(ctx);
129
+ if (!(exports.READONLY_SETTINGS_KEY in settings))
130
+ return [];
131
+ const stored = normalizeReadonlyNames(settings[exports.READONLY_SETTINGS_KEY]);
132
+ const live = stored.filter((name) => ruleFileExists(ctx, name));
133
+ const raw = settings[exports.READONLY_SETTINGS_KEY];
134
+ const changed = !Array.isArray(raw) || live.length !== raw.length;
135
+ if (changed) {
136
+ settings[exports.READONLY_SETTINGS_KEY] = live;
137
+ writeSettings(ctx, settings);
138
+ }
139
+ return live;
140
+ }
141
+ function isRuleFileReadonly(ctx, name) {
142
+ return getReadonlyRuleFileNames(ctx).some((item) => fold(item) === fold(name));
143
+ }
144
+ /**
145
+ * File names compare case-insensitively on macOS / Windows, where `Foo.txt`
146
+ * and `foo.txt` are the same file; the lock must not be dodged by changing case.
147
+ */
148
+ const CASE_INSENSITIVE_FS = process.platform === 'darwin' || process.platform === 'win32';
149
+ const fold = (name) => (CASE_INSENSITIVE_FS ? name.toLowerCase() : name);
150
+ /** The common write gate: throws RulesetReadonlyError for the first locked name. */
151
+ function assertRuleFilesWritable(ctx, names) {
152
+ const locked = getReadonlyRuleFileNames(ctx);
153
+ if (locked.length === 0)
154
+ return;
155
+ const byKey = new Map(locked.map((name) => [fold(name), name]));
156
+ for (const name of names) {
157
+ const hit = byKey.get(fold(name));
158
+ if (hit !== undefined)
159
+ throw new RulesetReadonlyError(hit);
160
+ }
161
+ }
162
+ function assertRuleFileWritable(ctx, name) {
163
+ assertRuleFilesWritable(ctx, [name]);
164
+ }
165
+ /** Lock / unlock. Only the token-protected UI route calls this. */
166
+ function setRuleFileReadonly(ctx, name, readonly) {
167
+ if (!ruleFileExists(ctx, name)) {
168
+ throw new Error('规则文件不存在');
169
+ }
170
+ const current = getReadonlyRuleFileNames(ctx);
171
+ const next = readonly
172
+ ? (current.includes(name) ? current : [...current, name])
173
+ : current.filter((item) => item !== name);
174
+ const settings = readSettings(ctx);
175
+ settings[exports.READONLY_SETTINGS_KEY] = next;
176
+ writeSettings(ctx, settings);
177
+ return next;
178
+ }
179
+ // ---------- UI token + same-origin check ----------
180
+ const uiTokens = new WeakMap();
181
+ /** One random token per server instance; handed to the page, never to CLI/MCP. */
182
+ function getUiToken(ctx) {
183
+ let token = uiTokens.get(ctx);
184
+ if (!token) {
185
+ token = crypto.randomBytes(32).toString('hex');
186
+ uiTokens.set(ctx, token);
187
+ }
188
+ return token;
189
+ }
190
+ /** Put the token into index.html as `<meta name="meddle-ui-token">`. */
191
+ function injectUiToken(html, token) {
192
+ const meta = `<meta name="${exports.UI_TOKEN_META_NAME}" content="${token}">`;
193
+ if (html.includes('</head>'))
194
+ return html.replace('</head>', `${meta}</head>`);
195
+ return meta + html;
196
+ }
197
+ function headerValue(req, name) {
198
+ const value = req.headers[name];
199
+ if (Array.isArray(value))
200
+ return value[0] || '';
201
+ return typeof value === 'string' ? value : '';
202
+ }
203
+ /**
204
+ * Same-origin check. Browsers send `Sec-Fetch-Site`; when present it must be
205
+ * `same-origin`. When `Origin` is present its host must equal `Host`. A request
206
+ * with neither header (plain curl / CLI / MCP fetch) is rejected.
207
+ */
208
+ function isSameOriginRequest(req) {
209
+ const site = headerValue(req, 'sec-fetch-site');
210
+ const origin = headerValue(req, 'origin');
211
+ if (!site && !origin)
212
+ return false;
213
+ if (site && site !== 'same-origin')
214
+ return false;
215
+ if (origin) {
216
+ const host = headerValue(req, 'host');
217
+ try {
218
+ if (!host || new URL(origin).host !== host)
219
+ return false;
220
+ }
221
+ catch {
222
+ return false;
223
+ }
224
+ }
225
+ return true;
226
+ }
227
+ function hasValidUiToken(req, ctx) {
228
+ const provided = headerValue(req, exports.UI_TOKEN_HEADER);
229
+ const expected = getUiToken(ctx);
230
+ if (!provided || provided.length !== expected.length)
231
+ return false;
232
+ return crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
233
+ }
234
+ //# sourceMappingURL=ruleset-lock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ruleset-lock.js","sourceRoot":"","sources":["../../server/ruleset-lock.ts"],"names":[],"mappings":"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"}
package/mcp-server.js CHANGED
@@ -18,6 +18,7 @@ const {
18
18
  } = require('./bin/lib/sessions')
19
19
  const { GLOBAL_DEFAULT_PORT } = require('./bin/lib/session-args')
20
20
  const { buildProxySpawn, getReentryArgv } = require('./bin/lib/spawn-proxy')
21
+ const { addRouteRule, updateRouteRule, deleteRouteRule } = require('./bin/lib/rule-file-edit')
21
22
 
22
23
  const meddleDir = resolveMeddleHome()
23
24
  const mcpFile = path.join(meddleDir, 'mcp-proxy-url.json')
@@ -53,11 +54,55 @@ function proxyApiForSession(sessionId, method, pathname, body) {
53
54
  const opts = { method, headers: { 'Content-Type': 'application/json' } }
54
55
  if (body !== undefined) opts.body = typeof body === 'string' ? body : JSON.stringify(body)
55
56
  return fetch(url, opts).then((res) => {
56
- if (!res.ok) return res.text().then((t) => { throw new Error(t || res.statusText) })
57
- return res.headers.get('content-type')?.includes('application/json') ? res.json() : res.text()
57
+ if (!res.ok) return res.text().then((t) => { throw apiError(res, t) })
58
+ return res.headers.get('content-type')?.includes('application/json') ? res.json() : readTextKeepBom(res)
58
59
  })
59
60
  }
60
61
 
62
+ /**
63
+ * Issue #145: `Response.text()` strips a leading UTF-8 BOM, so a rule file read
64
+ * and written back through the API would silently lose it. Decode keeping it.
65
+ */
66
+ function readTextKeepBom(res) {
67
+ return res.arrayBuffer().then((buf) => new TextDecoder('utf-8', { ignoreBOM: true }).decode(buf))
68
+ }
69
+
70
+ // ---------- Issue #137: 只读规则集 ----------
71
+ // 锁只存在 settings.json,只能在 Meddle Web 界面中加锁 / 解锁;MCP 没有对应工具,只读取状态并在写入被拒时如实返回。
72
+ const RULESET_READONLY = 'RULESET_READONLY'
73
+ const RULESET_READONLY_MESSAGE = '该规则集已设为只读,只能在 Meddle 界面中解锁'
74
+ const READONLY_NOTE = '只读规则集会被拒绝(错误码 RULESET_READONLY,只能在 Meddle 界面中解锁)。'
75
+
76
+ /** 把代理 API 的错误响应转成 Error,保留结构化的 code / ruleFile。 */
77
+ function apiError(res, text) {
78
+ const err = new Error(text || res.statusText)
79
+ err.status = res.status
80
+ try {
81
+ const data = JSON.parse(text)
82
+ if (data && typeof data === 'object') {
83
+ if (typeof data.code === 'string') err.code = data.code
84
+ if (typeof data.ruleFile === 'string') err.ruleFile = data.ruleFile
85
+ }
86
+ } catch (_) {}
87
+ return err
88
+ }
89
+
90
+ function rulesetReadonlyResult(ruleFile) {
91
+ return {
92
+ content: [{
93
+ type: 'text',
94
+ text: JSON.stringify({ error: { code: RULESET_READONLY, message: RULESET_READONLY_MESSAGE, ruleFile: ruleFile || null } }, null, 2)
95
+ }],
96
+ isError: true
97
+ }
98
+ }
99
+
100
+ /** 统一的错误返回:只读拒绝给出结构化结果,其它错误保持原样。 */
101
+ function toolError(e, ruleFile) {
102
+ if (e && e.code === RULESET_READONLY) return rulesetReadonlyResult(e.ruleFile || ruleFile)
103
+ return { content: [{ type: 'text', text: e.message }], isError: true }
104
+ }
105
+
61
106
  const FILE_PATTERN = /^file:\/\//
62
107
  const LOCAL_FILE_PATTERN = /^[A-Za-z]:\\|^\/|^\\/
63
108
 
@@ -109,37 +154,6 @@ function parseEprc(content) {
109
154
  return parseEprcWithExclusions(content).ruleMap
110
155
  }
111
156
 
112
- /** 将 pattern -> target 对象转回规则文件文本(与 helpers.ruleMapToEprcText 一致,保留 exclusions) */
113
- function ruleMapToEprcText(ruleMap, excludeMap) {
114
- const entries = Object.entries(ruleMap)
115
- if (entries.length === 0) return ''
116
- const byTargetAndExclusions = {}
117
- entries.forEach(([rule, target]) => {
118
- const bm = target.match(/\[([^\]]+)\]/)
119
- const groupKey = bm ? target.replace(bm[0], '') : target
120
- const displayRule = bm ? rule.replace(bm[1], bm[0]) : rule
121
- const exclusions = excludeMap?.[rule] || []
122
- const exclusionsKey = exclusions.join(',')
123
- // Create a compound key that includes both target and exclusions
124
- const compoundKey = `${groupKey}|||${exclusionsKey}`
125
- if (!byTargetAndExclusions[compoundKey]) {
126
- byTargetAndExclusions[compoundKey] = { target: groupKey, rules: [], exclusions }
127
- }
128
- byTargetAndExclusions[compoundKey].rules.push(displayRule)
129
- })
130
- return Object.entries(byTargetAndExclusions)
131
- .map(([, { target, rules, exclusions }]) => {
132
- let displayTarget = target
133
- if (FILE_PATTERN.test(target)) {
134
- displayTarget = target.replace(/^file:\/\//, '').replace(/\//g, path.sep)
135
- }
136
- const exclusionStr = exclusions.map(e => `!${e}`).join(' ')
137
- const rulesStr = rules.join(' ')
138
- return exclusionStr ? `${rulesStr} ${exclusionStr} ${displayTarget}` : `${rulesStr} ${displayTarget}`
139
- })
140
- .join('\n')
141
- }
142
-
143
157
  /** 请求代理 API */
144
158
  function proxyApi(method, pathname, body) {
145
159
  const base = getProxyBaseUrl()
@@ -147,9 +161,8 @@ function proxyApi(method, pathname, body) {
147
161
  const opts = { method, headers: { 'Content-Type': 'application/json' } }
148
162
  if (body !== undefined) opts.body = typeof body === 'string' ? body : JSON.stringify(body)
149
163
  return fetch(url, opts).then((res) => {
150
- const text = () => res.text()
151
- if (!res.ok) return text().then((t) => { throw new Error(t || res.statusText) })
152
- return res.headers.get('content-type')?.includes('application/json') ? res.json() : text()
164
+ if (!res.ok) return res.text().then((t) => { throw new Error(t || res.statusText) })
165
+ return res.headers.get('content-type')?.includes('application/json') ? res.json() : readTextKeepBom(res)
153
166
  })
154
167
  }
155
168
 
@@ -395,7 +408,7 @@ mcpServer.registerTool('mock_rule_list', {
395
408
  const rules = await proxyApiForSession(session, 'GET', '/api/mocks')
396
409
  return { content: [{ type: 'text', text: JSON.stringify(rules, null, 2) }] }
397
410
  } catch (e) {
398
- return { content: [{ type: 'text', text: e.message }], isError: true }
411
+ return toolError(e)
399
412
  }
400
413
  })
401
414
 
@@ -435,7 +448,7 @@ mcpServer.registerTool('mock_rule_add', {
435
448
  content: [{ type: 'text', text: `已添加 Mock 规则 id=${rule.id}: ${rule.name} (${rule.urlPattern})` }]
436
449
  }
437
450
  } catch (e) {
438
- return { content: [{ type: 'text', text: e.message }], isError: true }
451
+ return toolError(e)
439
452
  }
440
453
  })
441
454
 
@@ -470,7 +483,7 @@ mcpServer.registerTool('mock_rule_update', {
470
483
  content: [{ type: 'text', text: `已更新 Mock 规则 id=${id}: ${rule.name ?? '(未改)'}` }]
471
484
  }
472
485
  } catch (e) {
473
- return { content: [{ type: 'text', text: e.message }], isError: true }
486
+ return toolError(e)
474
487
  }
475
488
  })
476
489
 
@@ -485,14 +498,14 @@ mcpServer.registerTool('mock_rule_delete', {
485
498
  await proxyApiForSession(session, 'DELETE', `/api/mocks/${id}`)
486
499
  return { content: [{ type: 'text', text: `已删除 Mock 规则 id=${id}` }] }
487
500
  } catch (e) {
488
- return { content: [{ type: 'text', text: e.message }], isError: true }
501
+ return toolError(e)
489
502
  }
490
503
  })
491
504
 
492
505
  // ---------- 路由规则(可选 session 参数) ----------
493
506
 
494
507
  mcpServer.registerTool('route_rule_list', {
495
- description: '列出所有路由规则文件;可选指定 ruleFile 获取该文件下的规则列表(pattern -> target 及 exclusions)。可选 session 参数指定操作的 session(不传则操作默认 session)。',
508
+ description: '列出所有路由规则文件(每项含 readonly: true|false);可选指定 ruleFile 获取该文件下的规则列表(pattern -> target 及 exclusions,含 readonly)。readonly 只能在 Meddle 界面中修改,MCP 没有加锁/解锁工具。可选 session 参数指定操作的 session(不传则操作默认 session)。',
496
509
  inputSchema: {
497
510
  session: z.string().optional().describe('目标 session id,不传则操作默认 session'),
498
511
  ruleFile: z.string().optional().describe('规则文件名(不含 .txt),不传则只返回文件列表')
@@ -520,11 +533,13 @@ mcpServer.registerTool('route_rule_list', {
520
533
  for (const exclusions of Object.values(excludeMap)) {
521
534
  totalExclusions += exclusions.length
522
535
  }
536
+ const info = Array.isArray(files) ? files.find((item) => item && item.name === ruleFile.trim()) : null
537
+ const readonly = Boolean(info && info.readonly)
523
538
  return {
524
- content: [{ type: 'text', text: JSON.stringify({ ruleFile, rules: displayRules, ruleCount: Object.keys(displayRules).length, exclusionCount: totalExclusions }, null, 2) }]
539
+ content: [{ type: 'text', text: JSON.stringify({ ruleFile, readonly, rules: displayRules, ruleCount: Object.keys(displayRules).length, exclusionCount: totalExclusions }, null, 2) }]
525
540
  }
526
541
  } catch (e) {
527
- return { content: [{ type: 'text', text: e.message }], isError: true }
542
+ return toolError(e)
528
543
  }
529
544
  })
530
545
 
@@ -549,12 +564,12 @@ mcpServer.registerTool('route_rule_active_get', {
549
564
  }]
550
565
  }
551
566
  } catch (e) {
552
- return { content: [{ type: 'text', text: e.message }], isError: true }
567
+ return toolError(e)
553
568
  }
554
569
  })
555
570
 
556
571
  mcpServer.registerTool('route_rule_active_set', {
557
- description: '设置当前激活的路由规则文件。可选 session 参数指定操作的 session(不传则操作默认 session)。',
572
+ description: '设置当前激活的路由规则文件。会改变启用状态的规则集中若有只读规则集则整体拒绝、不做任何修改:' + READONLY_NOTE + '可选 session 参数指定操作的 session(不传则操作默认 session)。',
558
573
  inputSchema: {
559
574
  session: z.string().optional().describe('目标 session id,不传则操作默认 session'),
560
575
  ruleFile: z.string().describe('要设为当前激活的规则文件名(不含 .txt)')
@@ -571,6 +586,10 @@ mcpServer.registerTool('route_rule_active_set', {
571
586
  return { content: [{ type: 'text', text: `未找到规则文件: ${targetName}` }], isError: true }
572
587
  }
573
588
 
589
+ // 先整体检查,避免锁住的规则集让切换只做了一半
590
+ const blocked = files.find((item) => item && item.readonly && !!item.enabled !== (item.name === targetName))
591
+ if (blocked) return rulesetReadonlyResult(blocked.name)
592
+
574
593
  for (const item of files) {
575
594
  const nextEnabled = item.name === targetName
576
595
  if (!!item.enabled === nextEnabled) continue
@@ -585,12 +604,12 @@ mcpServer.registerTool('route_rule_active_set', {
585
604
  }]
586
605
  }
587
606
  } catch (e) {
588
- return { content: [{ type: 'text', text: e.message }], isError: true }
607
+ return toolError(e)
589
608
  }
590
609
  })
591
610
 
592
611
  mcpServer.registerTool('route_rule_create_file', {
593
- description: '创建新的路由规则文件。可选 session 参数指定操作的 session(不传则操作默认 session)。',
612
+ description: '创建新的路由规则文件(新文件默认可读写)。同名规则文件已存在时失败,不会覆盖已有规则集,因此只读规则集会被拒绝。可选 session 参数指定操作的 session(不传则操作默认 session)。',
594
613
  inputSchema: {
595
614
  session: z.string().optional().describe('目标 session id,不传则操作默认 session'),
596
615
  name: z.string().describe('规则文件名称(不含 .txt),如 dev、staging;非法字符会被替换为下划线'),
@@ -609,18 +628,18 @@ mcpServer.registerTool('route_rule_create_file', {
609
628
  }]
610
629
  }
611
630
  } catch (e) {
612
- return { content: [{ type: 'text', text: e.message }], isError: true }
631
+ return toolError(e)
613
632
  }
614
633
  })
615
634
 
616
635
  mcpServer.registerTool('route_rule_add', {
617
- description: '在指定规则文件中添加一条路由规则(pattern -> target)。若 pattern 已存在则覆盖。可选 session 参数指定操作的 session(不传则操作默认 session)。',
636
+ description: '在指定规则文件中添加一条路由规则(pattern -> target)。若 pattern 已存在则覆盖其 target。exclusions 不传时保留该规则原有的排除规则(新规则无排除规则),传 [] 清空。只改这一行,文件中的注释、空行和其他规则保持原样。' + READONLY_NOTE + '可选 session 参数指定操作的 session(不传则操作默认 session)。',
618
637
  inputSchema: {
619
638
  session: z.string().optional().describe('目标 session id,不传则操作默认 session'),
620
639
  ruleFile: z.string().describe('规则文件名(不含 .txt)'),
621
640
  pattern: z.string().describe('匹配完整请求 URL。支持:(1) 正则表达式,如 ^https://a\\.com/api; (2) 通配符,如 *.wps.cn 匹配 wps.cn、plus.wps.cn、deep.plus.wps.cn;(3) [marker] 路径重写,如 ^https://cdn.com/[assets] 将 assets 后的路径拼接到 target'),
622
641
  target: z.string().describe('转发目标。若仅 host:port 则继承原请求的协议、pathname、query;若完整 URL 则直接使用;若原请求是 websocket 而 target 是 http(s),自动转为 ws(s)'),
623
- exclusions: z.array(z.string()).optional().describe('排除规则列表,匹配完整 URL。若任一 exclusion 命中则跳过此规则继续匹配下一条。如 ["api/health", "^https://a\\.com/internal"]')
642
+ exclusions: z.array(z.string()).optional().describe('排除规则列表,匹配完整 URL。若任一 exclusion 命中则跳过此规则继续匹配下一条。如 ["api/health", "^https://a\\.com/internal"]。不传时保留该规则原有的排除规则(新规则无排除规则);传 [] 清空')
624
643
  }
625
644
  }, async ({ session, ruleFile, pattern, target, exclusions }) => {
626
645
  try {
@@ -632,34 +651,25 @@ mcpServer.registerTool('route_rule_add', {
632
651
  return { content: [{ type: 'text', text: `规则文件不存在或代理未启动: ${err.message}` }], isError: true }
633
652
  }
634
653
  const text = typeof content === 'string' ? content : String(content)
635
- const { ruleMap, excludeMap } = parseEprcWithExclusions(text)
636
- const pat = pattern.trim()
637
- const tgt = target.trim()
638
- const bm = pat.match(/\[([^\]]+)\]/)
639
- if (bm) {
640
- ruleMap[pat.replace(bm[0], bm[1])] = tgt + bm[0]
641
- excludeMap[pat.replace(bm[0], bm[1])] = exclusions || []
642
- } else {
643
- ruleMap[pat] = tgt
644
- excludeMap[pat] = exclusions || []
645
- }
646
- const newContent = ruleMapToEprcText(ruleMap, excludeMap)
654
+ // Issue #145: edit only this rule's line(s); other lines keep their exact bytes.
655
+ // Without `exclusions` an existing rule keeps its own exclusions.
656
+ const { content: newContent } = addRouteRule(text, { pattern: pattern.trim(), target: target.trim(), exclusions })
647
657
  await proxyApiForSession(session, 'PUT', `/api/rule-files/${name}/content`, { content: newContent })
648
658
  const exclusionInfo = exclusions && exclusions.length > 0 ? `,排除规则: ${exclusions.join(', ')}` : ''
649
659
  return { content: [{ type: 'text', text: `已添加规则: ${pattern} -> ${target}${exclusionInfo}` }] }
650
660
  } catch (e) {
651
- return { content: [{ type: 'text', text: e.message }], isError: true }
661
+ return toolError(e)
652
662
  }
653
663
  })
654
664
 
655
665
  mcpServer.registerTool('route_rule_update', {
656
- description: '修改指定规则文件中某条规则的 target 和 exclusions(按 pattern 查找)。可选 session 参数指定操作的 session(不传则操作默认 session)。',
666
+ description: '修改指定规则文件中某条规则的 target 和 exclusions(按 pattern 查找)。exclusions 不传时保留该规则原有的排除规则,传 [] 清空。只改这一行,文件中的注释、空行和其他规则保持原样。' + READONLY_NOTE + '可选 session 参数指定操作的 session(不传则操作默认 session)。',
657
667
  inputSchema: {
658
668
  session: z.string().optional().describe('目标 session id,不传则操作默认 session'),
659
669
  ruleFile: z.string().describe('规则文件名(不含 .txt)'),
660
670
  pattern: z.string().describe('要修改的 pattern(需与现有规则一致,支持带 [marker] 的写法)'),
661
671
  newTarget: z.string().optional().describe('新的转发目标。若仅 host:port 则继承原请求的协议、pathname、query;若完整 URL 则直接使用'),
662
- exclusions: z.array(z.string()).optional().describe('新的排除规则列表,匹配完整 URL。设为空数组 [] 可清除所有排除规则')
672
+ exclusions: z.array(z.string()).optional().describe('新的排除规则列表,匹配完整 URL。不传时保留该规则原有的排除规则;传 [] 清空')
663
673
  }
664
674
  }, async ({ session, ruleFile, pattern, newTarget, exclusions }) => {
665
675
  try {
@@ -672,32 +682,24 @@ mcpServer.registerTool('route_rule_update', {
672
682
  return { content: [{ type: 'text', text: `规则文件不存在或代理未启动: ${err.message}` }], isError: true }
673
683
  }
674
684
  const text = typeof content === 'string' ? content : String(content)
675
- const { ruleMap, excludeMap } = parseEprcWithExclusions(text)
676
- const bm = pat.match(/\[([^\]]+)\]/)
677
- const internalKey = bm ? pat.replace(bm[0], bm[1]) : pat
678
- if (!Object.prototype.hasOwnProperty.call(ruleMap, internalKey)) {
685
+ // Issue #145: edit only this rule's line(s); other lines keep their exact bytes.
686
+ const result = updateRouteRule(text, { pattern: pat, target: newTarget ? newTarget.trim() : undefined, exclusions })
687
+ if (!result.found) {
679
688
  return { content: [{ type: 'text', text: `未找到 pattern: ${pat}` }], isError: true }
680
689
  }
681
- if (newTarget) {
682
- const tgt = newTarget.trim()
683
- ruleMap[internalKey] = bm ? tgt + bm[0] : tgt
684
- }
685
- if (exclusions !== undefined) {
686
- excludeMap[internalKey] = exclusions
687
- }
688
- const newContent = ruleMapToEprcText(ruleMap, excludeMap)
690
+ const newContent = result.content
689
691
  await proxyApiForSession(session, 'PUT', `/api/rule-files/${name}/content`, { content: newContent })
690
692
  const targetInfo = newTarget ? `目标: ${newTarget}` : ''
691
693
  const exclusionInfo = exclusions !== undefined ? (exclusions.length > 0 ? `排除规则: ${exclusions.join(', ')}` : '已清除排除规则') : ''
692
694
  const parts = [targetInfo, exclusionInfo].filter(Boolean).join(', ')
693
695
  return { content: [{ type: 'text', text: `已更新规则: ${pat}${parts ? ` (${parts})` : ''}` }] }
694
696
  } catch (e) {
695
- return { content: [{ type: 'text', text: e.message }], isError: true }
697
+ return toolError(e)
696
698
  }
697
699
  })
698
700
 
699
701
  mcpServer.registerTool('route_rule_delete', {
700
- description: '从指定规则文件中删除一条路由规则(按 pattern)。可选 session 参数指定操作的 session(不传则操作默认 session)。',
702
+ description: '从指定规则文件中删除一条路由规则(按 pattern)。' + READONLY_NOTE + '可选 session 参数指定操作的 session(不传则操作默认 session)。',
701
703
  inputSchema: {
702
704
  session: z.string().optional().describe('目标 session id,不传则操作默认 session'),
703
705
  ruleFile: z.string().describe('规则文件名(不含 .txt)'),
@@ -714,19 +716,16 @@ mcpServer.registerTool('route_rule_delete', {
714
716
  return { content: [{ type: 'text', text: `规则文件不存在或代理未启动: ${err.message}` }], isError: true }
715
717
  }
716
718
  const text = typeof content === 'string' ? content : String(content)
717
- const { ruleMap, excludeMap } = parseEprcWithExclusions(text)
718
- const bm = pat.match(/\[([^\]]+)\]/)
719
- const internalKey = bm ? pat.replace(bm[0], bm[1]) : pat
720
- if (!Object.prototype.hasOwnProperty.call(ruleMap, internalKey)) {
719
+ // Issue #145: remove only this rule; other lines keep their exact bytes.
720
+ const result = deleteRouteRule(text, { pattern: pat })
721
+ if (!result.found) {
721
722
  return { content: [{ type: 'text', text: `未找到 pattern: ${pat}` }], isError: true }
722
723
  }
723
- delete ruleMap[internalKey]
724
- delete excludeMap[internalKey]
725
- const newContent = ruleMapToEprcText(ruleMap, excludeMap)
724
+ const newContent = result.content
726
725
  await proxyApiForSession(session, 'PUT', `/api/rule-files/${name}/content`, { content: newContent })
727
726
  return { content: [{ type: 'text', text: `已删除规则: ${pat}` }] }
728
727
  } catch (e) {
729
- return { content: [{ type: 'text', text: e.message }], isError: true }
728
+ return toolError(e)
730
729
  }
731
730
  })
732
731
 
@@ -789,7 +788,7 @@ mcpServer.registerTool('route_preview', {
789
788
  content: [{ type: 'text', text: JSON.stringify(output, null, 2) }]
790
789
  }
791
790
  } catch (e) {
792
- return { content: [{ type: 'text', text: e.message }], isError: true }
791
+ return toolError(e)
793
792
  }
794
793
  })
795
794
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jonathanleelx/meddle",
3
- "version": "0.5.0-beta.23",
3
+ "version": "0.5.0-beta.25",
4
4
  "description": "A simple development proxy server with custom routing rules, mock responses, HTTPS interception, and a plugin system",
5
5
  "type": "commonjs",
6
6
  "main": "index.js",