ultra-lean-mcp-proxy 0.3.0

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.
package/bin/cli.mjs ADDED
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Ultra Lean MCP Proxy - CLI entry point.
5
+ *
6
+ * Commands:
7
+ * install [--dry-run] [--client NAME] [--skip SERVER] [--offline] [--runtime npm|pip] [--no-wrap-url] [-v]
8
+ * uninstall [--dry-run] [--client NAME] [--runtime npm|pip] [--all] [-v]
9
+ * status
10
+ * wrap-cloud [--dry-run] [--runtime npm|pip] [--suffix NAME_SUFFIX] [-v]
11
+ * proxy [v2 flags] [--stats] [--trace-rpc] [--runtime npm] -- <upstream-command>
12
+ * watch [--interval SEC] [--daemon] [--stop] [--offline] [--no-wrap-url] [-v]
13
+ */
14
+
15
+ import { fileURLToPath, pathToFileURL } from 'node:url';
16
+ import { dirname, join } from 'node:path';
17
+
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = dirname(__filename);
20
+
21
+ // Helper: dynamic import by file path (works cross-platform with ESM)
22
+ function importLocal(relPath) {
23
+ const absPath = join(__dirname, '..', relPath);
24
+ return import(pathToFileURL(absPath).href);
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Minimal argument parser -- no external deps
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function parseArgs(argv) {
32
+ const args = argv.slice(0);
33
+ const result = {
34
+ command: null,
35
+ flags: {},
36
+ positional: [],
37
+ rest: [], // everything after "--"
38
+ };
39
+
40
+ function setFlagValue(key, value) {
41
+ if (!(key in result.flags)) {
42
+ result.flags[key] = value;
43
+ return;
44
+ }
45
+ if (Array.isArray(result.flags[key])) {
46
+ result.flags[key].push(value);
47
+ return;
48
+ }
49
+ result.flags[key] = [result.flags[key], value];
50
+ }
51
+
52
+ // Extract the subcommand (first non-flag token)
53
+ let i = 0;
54
+ while (i < args.length) {
55
+ if (args[i] === '--') {
56
+ result.rest = args.slice(i + 1);
57
+ break;
58
+ }
59
+ if (args[i].startsWith('-')) {
60
+ // flag
61
+ const raw = args[i].replace(/^-+/, '');
62
+ // Check for --flag=value
63
+ if (args[i].includes('=')) {
64
+ const [key, ...valParts] = raw.split('=');
65
+ setFlagValue(key, valParts.join('='));
66
+ } else if (
67
+ i + 1 < args.length &&
68
+ !args[i + 1].startsWith('-') &&
69
+ args[i + 1] !== '--'
70
+ ) {
71
+ // Peek: boolean-like short flags that never take a value
72
+ const boolFlags = new Set([
73
+ 'v', 'verbose', 'dry-run', 'stats', 'offline', 'all', 'help', 'h',
74
+ 'daemon', 'stop', 'trace-rpc', 'no-wrap-url', 'include-url',
75
+ 'strict-config', 'dump-effective-config',
76
+ 'no-cloud',
77
+ 'enable-result-compression', 'disable-result-compression',
78
+ 'enable-delta-responses', 'disable-delta-responses',
79
+ 'enable-lazy-loading', 'disable-lazy-loading',
80
+ 'enable-tools-hash-sync', 'disable-tools-hash-sync',
81
+ 'enable-caching', 'disable-caching',
82
+ ]);
83
+ if (boolFlags.has(raw)) {
84
+ result.flags[raw] = true;
85
+ } else {
86
+ setFlagValue(raw, args[i + 1]);
87
+ i++;
88
+ }
89
+ } else {
90
+ result.flags[raw] = true;
91
+ }
92
+ } else if (result.command === null) {
93
+ result.command = args[i];
94
+ } else {
95
+ result.positional.push(args[i]);
96
+ }
97
+ i++;
98
+ }
99
+
100
+ return result;
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Help text
105
+ // ---------------------------------------------------------------------------
106
+
107
+ const HELP = `
108
+ ultra-lean-mcp-proxy - lightweight optimization proxy for MCP
109
+
110
+ Usage:
111
+ ultra-lean-mcp-proxy install [--dry-run] [--client NAME] [--skip SERVER] [--offline] [--runtime npm|pip] [--no-wrap-url] [--no-cloud] [--suffix NAME] [-v]
112
+ ultra-lean-mcp-proxy uninstall [--dry-run] [--client NAME] [--runtime npm|pip] [--all] [-v]
113
+ ultra-lean-mcp-proxy status
114
+ ultra-lean-mcp-proxy wrap-cloud [--dry-run] [--runtime npm|pip] [--suffix NAME_SUFFIX] [-v]
115
+ ultra-lean-mcp-proxy proxy [v2 flags] [--stats] [--trace-rpc] [--runtime npm] -- <upstream-command>
116
+ ultra-lean-mcp-proxy watch [--interval SEC] [--daemon] [--stop] [--offline] [--no-wrap-url] [--suffix NAME] [--cloud-interval SEC] [-v]
117
+
118
+ Options:
119
+ -v, --verbose Verbose output
120
+ -h, --help Show this help message
121
+
122
+ Commands:
123
+ install Wrap MCP server entries in client configs to route through proxy
124
+ uninstall Remove proxy wrapping from client configs
125
+ status Show which clients/servers are currently wrapped
126
+ wrap-cloud Mirror cloud-scoped Claude MCP URL connectors into local config, already wrapped
127
+ proxy Run as stdio proxy in front of an upstream MCP server
128
+ --enable-result-compression / --disable-result-compression
129
+ --enable-delta-responses / --disable-delta-responses
130
+ --enable-lazy-loading / --disable-lazy-loading
131
+ --enable-tools-hash-sync / --disable-tools-hash-sync
132
+ --enable-caching / --disable-caching
133
+ --cache-ttl <seconds> --delta-min-savings <ratio> --lazy-mode off|minimal|search_only
134
+ watch Watch config files and auto-wrap new MCP servers
135
+ --daemon Run watcher as background daemon
136
+ --stop Stop running daemon
137
+ --interval Polling interval in seconds (default: 5)
138
+ --suffix Suffix for cloud connector names (default: -ulmp)
139
+ --cloud-interval Cloud discovery interval in seconds (default: 60)
140
+ `.trim();
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Main
144
+ // ---------------------------------------------------------------------------
145
+
146
+ async function main() {
147
+ const parsed = parseArgs(process.argv.slice(2));
148
+ const firstValue = (value, fallback = null) => (Array.isArray(value) ? value[value.length - 1] : (value ?? fallback));
149
+
150
+ if (parsed.flags.help || parsed.flags.h || !parsed.command) {
151
+ console.log(HELP);
152
+ process.exit(parsed.command ? 0 : 1);
153
+ }
154
+
155
+ const verbose = !!(parsed.flags.v || parsed.flags.verbose);
156
+
157
+ switch (parsed.command) {
158
+ // ---- install ----
159
+ case 'install': {
160
+ const { doInstall } = await importLocal('src/installer.mjs');
161
+ const skipRaw = parsed.flags.skip;
162
+ const wrapUrl = parsed.flags['no-wrap-url'] ? false : true;
163
+ const installRuntime = firstValue(parsed.flags.runtime, 'npm');
164
+ if (!['npm', 'pip'].includes(String(installRuntime))) {
165
+ console.error(`Error: invalid --runtime value "${installRuntime}". Expected "npm" or "pip".`);
166
+ process.exit(1);
167
+ }
168
+ await doInstall({
169
+ dryRun: !!parsed.flags['dry-run'],
170
+ clientFilter: firstValue(parsed.flags.client, null),
171
+ skipNames: Array.isArray(skipRaw) ? skipRaw : skipRaw ? [skipRaw] : [],
172
+ offline: !!parsed.flags.offline,
173
+ wrapUrl,
174
+ runtime: installRuntime,
175
+ verbose,
176
+ });
177
+
178
+ // Cloud connector discovery (enabled by default, opt-out with --no-cloud)
179
+ if (!parsed.flags['no-cloud']) {
180
+ const { commandExists, doWrapCloud } = await importLocal('src/installer.mjs');
181
+ if (commandExists('claude')) {
182
+ try {
183
+ const suffix = firstValue(parsed.flags.suffix, '-ulmp');
184
+ await doWrapCloud({
185
+ dryRun: !!parsed.flags['dry-run'],
186
+ runtime: installRuntime,
187
+ suffix,
188
+ verbose,
189
+ });
190
+ } catch (err) {
191
+ console.log(`[install] Cloud connector discovery failed: ${err.message}`);
192
+ }
193
+ } else {
194
+ console.log('[install] Cloud connector discovery skipped: claude CLI not found on PATH');
195
+ }
196
+ }
197
+ break;
198
+ }
199
+
200
+ // ---- uninstall ----
201
+ case 'uninstall': {
202
+ const { doUninstall } = await importLocal('src/installer.mjs');
203
+ await doUninstall({
204
+ dryRun: !!parsed.flags['dry-run'],
205
+ clientFilter: firstValue(parsed.flags.client, null),
206
+ runtime: firstValue(parsed.flags.runtime, 'npm'),
207
+ all: !!parsed.flags.all,
208
+ verbose,
209
+ });
210
+ break;
211
+ }
212
+
213
+ // ---- status ----
214
+ case 'status': {
215
+ const { showStatus } = await importLocal('src/installer.mjs');
216
+ await showStatus();
217
+ break;
218
+ }
219
+
220
+ // ---- wrap-cloud ----
221
+ case 'wrap-cloud': {
222
+ const { doWrapCloud } = await importLocal('src/installer.mjs');
223
+ const wrapCloudRuntime = firstValue(parsed.flags.runtime, 'npm');
224
+ if (!['npm', 'pip'].includes(String(wrapCloudRuntime))) {
225
+ console.error(`Error: invalid --runtime value "${wrapCloudRuntime}". Expected "npm" or "pip".`);
226
+ process.exit(1);
227
+ }
228
+ await doWrapCloud({
229
+ dryRun: !!parsed.flags['dry-run'],
230
+ runtime: wrapCloudRuntime,
231
+ suffix: firstValue(parsed.flags.suffix, '-ulmp'),
232
+ verbose,
233
+ });
234
+ break;
235
+ }
236
+
237
+ // ---- proxy ----
238
+ case 'proxy': {
239
+ const upstreamCmd = parsed.rest;
240
+ if (upstreamCmd.length === 0) {
241
+ console.error('Error: No upstream server command provided.');
242
+ console.error('Usage: ultra-lean-mcp-proxy proxy -- <command> [args...]');
243
+ console.error('Example: ultra-lean-mcp-proxy proxy -- npx @modelcontextprotocol/server-filesystem /tmp');
244
+ process.exit(1);
245
+ }
246
+ // --runtime is accepted but only used by the wrapper / installer; ignored here.
247
+ const stats = !!parsed.flags.stats;
248
+ const traceRpc = !!parsed.flags['trace-rpc'];
249
+ const toggle = (enableFlag, disableFlag) => {
250
+ if (parsed.flags[enableFlag]) return true;
251
+ if (parsed.flags[disableFlag]) return false;
252
+ return null;
253
+ };
254
+
255
+ const { runProxy } = await importLocal('src/proxy.mjs');
256
+ await runProxy(upstreamCmd, {
257
+ stats,
258
+ traceRpc,
259
+ verbose,
260
+ configPath: firstValue(parsed.flags.config, null),
261
+ sessionId: firstValue(parsed.flags['session-id'], null),
262
+ strictConfig: parsed.flags['strict-config'] ? true : null,
263
+ resultCompression: toggle('enable-result-compression', 'disable-result-compression'),
264
+ deltaResponses: toggle('enable-delta-responses', 'disable-delta-responses'),
265
+ lazyLoading: toggle('enable-lazy-loading', 'disable-lazy-loading'),
266
+ toolsHashSync: toggle('enable-tools-hash-sync', 'disable-tools-hash-sync'),
267
+ caching: toggle('enable-caching', 'disable-caching'),
268
+ cacheTtl: firstValue(parsed.flags['cache-ttl'], null),
269
+ deltaMinSavings: firstValue(parsed.flags['delta-min-savings'], null),
270
+ lazyMode: firstValue(parsed.flags['lazy-mode'], null),
271
+ toolsHashRefreshInterval: firstValue(parsed.flags['tools-hash-refresh-interval'], null),
272
+ searchTopK: firstValue(parsed.flags['search-top-k'], null),
273
+ resultCompressionMode: firstValue(parsed.flags['result-compression-mode'], null),
274
+ dumpEffectiveConfig: !!parsed.flags['dump-effective-config'],
275
+ });
276
+ break;
277
+ }
278
+
279
+ // ---- watch ----
280
+ case 'watch': {
281
+ const interval = parseInt(firstValue(parsed.flags.interval, 5), 10) || 5;
282
+ const offline = !!parsed.flags.offline;
283
+ const runtime = firstValue(parsed.flags.runtime, 'npm');
284
+ const wrapUrl = parsed.flags['no-wrap-url'] ? false : true;
285
+ const suffix = firstValue(parsed.flags.suffix, '-ulmp');
286
+ const cloudInterval = parseInt(firstValue(parsed.flags['cloud-interval'], 60), 10) || 60;
287
+
288
+ if (parsed.flags.stop) {
289
+ const { stopDaemon } = await importLocal('src/watcher.mjs');
290
+ stopDaemon();
291
+ } else if (parsed.flags.daemon) {
292
+ const { startDaemon } = await importLocal('src/watcher.mjs');
293
+ startDaemon({ interval, runtime, offline, wrapUrl, verbose, suffix, cloudInterval });
294
+ } else {
295
+ const { runWatch } = await importLocal('src/watcher.mjs');
296
+ await runWatch({ interval, runtime, offline, wrapUrl, verbose, suffix, cloudInterval });
297
+ }
298
+ break;
299
+ }
300
+
301
+ default:
302
+ console.error(`Unknown command: ${parsed.command}`);
303
+ console.log(HELP);
304
+ process.exit(1);
305
+ }
306
+ }
307
+
308
+ main().catch((err) => {
309
+ console.error(err.message || err);
310
+ process.exit(1);
311
+ });
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "ultra-lean-mcp-proxy",
3
+ "version": "0.3.0",
4
+ "description": "Ultra Lean MCP Proxy - lightweight optimization proxy for MCP (Node.js)",
5
+ "type": "module",
6
+ "bin": {
7
+ "ultra-lean-mcp-proxy": "./bin/cli.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "LICENSE"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/lean-agent-protocol/ultra-lean-mcp-proxy.git"
21
+ },
22
+ "keywords": ["mcp", "proxy", "optimization", "compression"]
23
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Definition compression for Ultra Lean MCP Proxy (Node.js port).
3
+ *
4
+ * Port of the Python compression rules from ultra-lean-mcp-core.
5
+ * Zero dependencies - uses built-in RegExp only.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Compression rules: [pattern, replacement]
10
+ // Patterns use the "gi" flags (global, case-insensitive) to match Python's
11
+ // re.IGNORECASE behaviour. Word-boundary \b works the same in JS.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const COMPRESSION_RULES = [
15
+ // Remove filler phrases
16
+ [/\bThis tool (?:will |can |is used to |enables (?:you|users|LLMs|AI assistants) to |allows (?:you|users|LLMs|AI assistants) to )/gi, ''],
17
+ [/\bThis server (?:enables|allows|provides)\b/gi, ''],
18
+ [/\bThis operation (?:will|can)\b/gi, ''],
19
+ [/\bYou can use this (?:tool |to )\b/gi, ''],
20
+ [/\bProvides? (?:the )?ability to\b/gi, ''],
21
+ [/\bProvides? access to\b/gi, 'Access'],
22
+ [/\bGives? (?:you )?access to\b/gi, 'Access'],
23
+ [/\bmust be provided\b/gi, 'required'],
24
+ [/\bshould be provided\b/gi, 'recommended'],
25
+ [/\bcan be used (?:to |for )\b/gi, 'for '],
26
+ [/\bEnables you to\b/gi, ''],
27
+ [/\bAllows you to\b/gi, ''],
28
+
29
+ // Simplify phrases
30
+ [/\bin order to\b/gi, 'to'],
31
+ [/\bas well as\b/gi, 'and'],
32
+ [/\bprior to\b/gi, 'before'],
33
+ [/\bwith respect to\b/gi, 'for'],
34
+
35
+ // Remove qualifiers
36
+ [/\bvery\b/gi, ''],
37
+ [/\bsimply\b/gi, ''],
38
+ [/\bbasically\b/gi, ''],
39
+ [/\bessentially\b/gi, ''],
40
+
41
+ // Shorten terms
42
+ [/\brepository\b/gi, 'repo'],
43
+ [/\bconfiguration\b/gi, 'config'],
44
+ [/\binformation\b/gi, 'info'],
45
+ [/\bdocumentation\b/gi, 'docs'],
46
+ [/\bapplication\b/gi, 'app'],
47
+ [/\bdatabase\b/gi, 'DB'],
48
+ [/\benvironment\b/gi, 'env'],
49
+ [/\bparameters\b/gi, 'params'],
50
+ [/\bparameter\b/gi, 'param'],
51
+
52
+ // Shorten verbs
53
+ [/\bretrieve(?:s)?\b/gi, 'get'],
54
+ [/\bfetch(?:es)?\b/gi, 'get'],
55
+ [/\bexecute(?:s)?\b/gi, 'run'],
56
+ [/\bgenerate(?:s)?\b/gi, 'create'],
57
+
58
+ // Shorten notes
59
+ [/\bfor example\b/gi, 'e.g.'],
60
+ [/\bsuch as\b/gi, 'like'],
61
+
62
+ // Clean up whitespace and punctuation
63
+ [/ +/g, ' '],
64
+ [/ +([.,;:])/g, '$1'],
65
+ [/^\s+|\s+$/g, ''],
66
+ ];
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Public API
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Compress a tool or parameter description string.
74
+ *
75
+ * Returns the original string untouched when it is falsy or shorter than 20
76
+ * characters (too short to benefit from compression).
77
+ *
78
+ * @param {string|null|undefined} desc
79
+ * @returns {string|null|undefined}
80
+ */
81
+ export function compressDescription(desc) {
82
+ if (!desc || desc.length < 20) {
83
+ return desc;
84
+ }
85
+
86
+ let result = desc;
87
+
88
+ for (const [pattern, replacement] of COMPRESSION_RULES) {
89
+ // Reset lastIndex for global regexes to ensure a clean match each time
90
+ pattern.lastIndex = 0;
91
+ result = result.replace(pattern, replacement);
92
+ }
93
+
94
+ // Collapse repeated dots
95
+ result = result.replace(/\.+/g, '.');
96
+
97
+ // Capitalise first letter after ". "
98
+ result = result.replace(/(\. )([a-z])/g, (_match, dot, letter) => dot + letter.toUpperCase());
99
+
100
+ // Capitalise very first character
101
+ if (result && result[0] === result[0].toLowerCase()) {
102
+ result = result[0].toUpperCase() + result.slice(1);
103
+ }
104
+
105
+ return result.trim();
106
+ }
107
+
108
+ /**
109
+ * Recursively compress description fields inside a JSON Schema object.
110
+ *
111
+ * Mutates the schema in-place and returns it for convenience.
112
+ *
113
+ * @param {object} schema
114
+ * @returns {object}
115
+ */
116
+ export function compressSchema(schema) {
117
+ if (typeof schema !== 'object' || schema === null) {
118
+ return schema;
119
+ }
120
+
121
+ if (typeof schema.description === 'string') {
122
+ schema.description = compressDescription(schema.description);
123
+ }
124
+
125
+ if (schema.properties && typeof schema.properties === 'object') {
126
+ for (const key of Object.keys(schema.properties)) {
127
+ const propSchema = schema.properties[key];
128
+ if (typeof propSchema === 'object' && propSchema !== null) {
129
+ compressSchema(propSchema);
130
+ }
131
+ }
132
+ }
133
+
134
+ if (schema.items && typeof schema.items === 'object') {
135
+ compressSchema(schema.items);
136
+ }
137
+
138
+ return schema;
139
+ }
140
+
141
+ /**
142
+ * Compress an entire MCP tools list (the `tools` array from tools/list).
143
+ *
144
+ * Each tool entry is expected to have `description` and `inputSchema` fields.
145
+ * The function mutates entries in-place and returns the array.
146
+ *
147
+ * @param {Array<object>} tools
148
+ * @returns {Array<object>}
149
+ */
150
+ export function compressManifest(tools) {
151
+ if (!Array.isArray(tools)) {
152
+ return tools;
153
+ }
154
+
155
+ for (const tool of tools) {
156
+ if (typeof tool !== 'object' || tool === null) {
157
+ continue;
158
+ }
159
+
160
+ if (typeof tool.description === 'string') {
161
+ tool.description = compressDescription(tool.description);
162
+ }
163
+
164
+ if (tool.inputSchema && typeof tool.inputSchema === 'object') {
165
+ compressSchema(tool.inputSchema);
166
+ }
167
+ }
168
+
169
+ return tools;
170
+ }