mcp-compress-router 1.6.0 → 1.6.1

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.
@@ -47,13 +47,39 @@ function serializeCacheWrite(cachePath, task) {
47
47
  function isNodeError(err) {
48
48
  return err instanceof Error && typeof err.code === 'string';
49
49
  }
50
+ /**
51
+ * Sanitizes a parsed cache store: keeps only entries that are plain
52
+ * objects carrying a `tools` array, and drops everything else. A cache
53
+ * file that is damaged in parts therefore degrades to the subset of
54
+ * still-valid entries instead of being rejected wholesale; a root that
55
+ * is not a plain object degrades to an empty store.
56
+ *
57
+ * @param parsed - The raw JSON.parse() result of the cache file.
58
+ * @returns A validated store containing only well-formed entries.
59
+ */
60
+ function sanitizeStore(parsed) {
61
+ const store = {};
62
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
63
+ return store;
64
+ }
65
+ for (const [serverName, entry] of Object.entries(parsed)) {
66
+ const candidate = entry;
67
+ if (candidate !== null && typeof candidate === 'object' && Array.isArray(candidate.tools)) {
68
+ store[serverName] = candidate;
69
+ }
70
+ }
71
+ return store;
72
+ }
50
73
  /**
51
74
  * Reads the full tool cache store from disk. Returns an empty object
52
- * when the file does not exist.
75
+ * when the file does not exist. A corrupted cache file (invalid JSON,
76
+ * a non-object root, or entries without a `tools` array) never throws:
77
+ * it is treated as empty or partially empty so a damaged cache can
78
+ * never break the router. The next write simply rebuilds the file.
53
79
  *
54
80
  * @param configPath - Absolute path to the mcp.json file.
55
- * @returns The tool cache store, or empty object if file is absent.
56
- * @throws If the file exists but contains invalid JSON.
81
+ * @returns The tool cache store (possibly empty or partial).
82
+ * @throws Only on unexpected I/O errors (e.g. permission denied).
57
83
  */
58
84
  async function readCacheStore(configPath) {
59
85
  const cachePath = getCachePath(configPath);
@@ -67,17 +93,36 @@ async function readCacheStore(configPath) {
67
93
  }
68
94
  throw new Error(`Failed to read tool cache file: ${cachePath}`, { cause: err });
69
95
  }
70
- let parsed;
71
96
  try {
72
- parsed = JSON.parse(raw);
97
+ return sanitizeStore(JSON.parse(raw));
73
98
  }
74
- catch (err) {
75
- throw new Error(`Tool cache file contains invalid JSON: ${cachePath}`, { cause: err });
99
+ catch {
100
+ // Invalid JSON: treat the whole cache as empty rather than throwing.
101
+ // loadToolCache reports "no cache" and saveToolCache rebuilds it.
102
+ return {};
76
103
  }
77
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
78
- throw new Error(`Tool cache file must contain a JSON object: ${cachePath}`);
104
+ }
105
+ /**
106
+ * Writes `data` to `cachePath` atomically: the content is first written
107
+ * to a unique temporary sibling file and then renamed over the target.
108
+ * Renaming within the same directory is atomic on POSIX, so a reader
109
+ * (or a crash mid-write) can never observe a truncated or half-written
110
+ * cache file — it sees either the previous complete file or the new
111
+ * complete file.
112
+ *
113
+ * @param cachePath - Target cache file path.
114
+ * @param data - Full file content to write.
115
+ */
116
+ async function writeCacheFileAtomic(cachePath, data) {
117
+ const tempPath = `${cachePath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
118
+ try {
119
+ await fs.writeFile(tempPath, data);
120
+ await fs.rename(tempPath, cachePath);
121
+ }
122
+ catch (err) {
123
+ await fs.unlink(tempPath).catch(() => { });
124
+ throw err;
79
125
  }
80
- return parsed;
81
126
  }
82
127
  /**
83
128
  * Saves discovered tools to the on-disk tool cache for a single server.
@@ -98,7 +143,7 @@ export async function saveToolCache(configPath, serverName, tools) {
98
143
  tools,
99
144
  cachedAt: new Date().toISOString(),
100
145
  };
101
- await fs.writeFile(cachePath, JSON.stringify(store, null, 2) + '\n');
146
+ await writeCacheFileAtomic(cachePath, JSON.stringify(store, null, 2) + '\n');
102
147
  });
103
148
  }
104
149
  /**
@@ -108,8 +153,8 @@ export async function saveToolCache(configPath, serverName, tools) {
108
153
  *
109
154
  * @param configPath - Absolute path to the mcp.json file.
110
155
  * @param serverName - The server whose cached tools to load.
111
- * @returns The cached tool descriptors, or `undefined` when not cached.
112
- * @throws If the cache file exists but contains invalid JSON.
156
+ * @returns The cached tool descriptors, or `undefined` when not cached
157
+ * (including when the cache file is missing or corrupted).
113
158
  */
114
159
  export async function loadToolCache(configPath, serverName) {
115
160
  const store = await readCacheStore(configPath);
@@ -143,7 +188,7 @@ export async function clearToolCache(configPath, serverName) {
143
188
  await fs.unlink(cachePath).catch(() => { });
144
189
  }
145
190
  else {
146
- await fs.writeFile(cachePath, JSON.stringify(store, null, 2) + '\n');
191
+ await writeCacheFileAtomic(cachePath, JSON.stringify(store, null, 2) + '\n');
147
192
  }
148
193
  });
149
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-compress-router",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Compress all connected MCP servers into a single router MCP to save tokens",
5
5
  "license": "MIT",
6
6
  "type": "module",