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.
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Generic structured JSON result compression for Ultra Lean MCP Proxy.
3
+ */
4
+
5
+ import { createHash } from 'node:crypto';
6
+ import { cloneJson } from './state.mjs';
7
+
8
+ function jsonSize(value) {
9
+ return Buffer.byteLength(JSON.stringify(value), 'utf-8');
10
+ }
11
+
12
+ function stableJson(value) {
13
+ return JSON.stringify(value, Object.keys(value || {}).sort());
14
+ }
15
+
16
+ export function makeCompressionOptions(overrides = {}) {
17
+ return {
18
+ mode: 'balanced', // off | balanced | aggressive
19
+ stripNulls: false,
20
+ stripDefaults: false,
21
+ minPayloadBytes: 512,
22
+ enableColumnar: true,
23
+ columnarMinRows: 8,
24
+ columnarMinFields: 2,
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ export class TokenCounter {
30
+ constructor() {
31
+ this.backend = 'heuristic';
32
+ this.reason = 'node_builtin_estimator';
33
+ }
34
+
35
+ count(value) {
36
+ const text = JSON.stringify(value);
37
+ return Math.max(1, Math.floor(text.length / 4));
38
+ }
39
+ }
40
+
41
+ function collectKeyFrequency(node, counter) {
42
+ if (Array.isArray(node)) {
43
+ for (const item of node) {
44
+ collectKeyFrequency(item, counter);
45
+ }
46
+ return;
47
+ }
48
+ if (node && typeof node === 'object') {
49
+ for (const [key, value] of Object.entries(node)) {
50
+ const name = String(key);
51
+ counter[name] = (counter[name] || 0) + 1;
52
+ collectKeyFrequency(value, counter);
53
+ }
54
+ }
55
+ }
56
+
57
+ function buildKeyAliases(counter, mode) {
58
+ if (mode === 'off') return {};
59
+ const minFreq = mode === 'aggressive' ? 1 : 2;
60
+ const candidates = Object.entries(counter)
61
+ .filter(([key, freq]) => freq >= minFreq && key.length > 2)
62
+ .sort((a, b) => {
63
+ if (a[1] !== b[1]) return b[1] - a[1];
64
+ return b[0].length - a[0].length;
65
+ });
66
+
67
+ const aliases = {};
68
+ for (let i = 0; i < candidates.length; i++) {
69
+ const key = candidates[i][0];
70
+ const alias = `k${i}`;
71
+ if (alias.length < key.length) {
72
+ aliases[key] = alias;
73
+ }
74
+ }
75
+ return aliases;
76
+ }
77
+
78
+ function isDefaultish(value) {
79
+ if (value === null || value === '' || value === 0 || value === false) return true;
80
+ if (Array.isArray(value) && value.length === 0) return true;
81
+ if (value && typeof value === 'object' && Object.keys(value).length === 0) return true;
82
+ return false;
83
+ }
84
+
85
+ function canColumnar(items, opts) {
86
+ if (!opts.enableColumnar) return [false, []];
87
+ if (!Array.isArray(items) || items.length < opts.columnarMinRows) return [false, []];
88
+ if (!items.every((item) => item && typeof item === 'object' && !Array.isArray(item))) {
89
+ return [false, []];
90
+ }
91
+ const firstKeys = Object.keys(items[0]);
92
+ if (firstKeys.length < opts.columnarMinFields) return [false, []];
93
+ const firstSet = new Set(firstKeys);
94
+ for (let i = 1; i < items.length; i++) {
95
+ const keys = Object.keys(items[i]);
96
+ if (keys.length !== firstSet.size) return [false, []];
97
+ if (keys.some((key) => !firstSet.has(key))) return [false, []];
98
+ }
99
+ return [true, firstKeys];
100
+ }
101
+
102
+ function encode(node, keyAlias, opts) {
103
+ if (Array.isArray(node)) {
104
+ const [asColumnar, columns] = canColumnar(node, opts);
105
+ if (asColumnar) {
106
+ const encodedColumns = columns.map((col) => keyAlias[col] || col);
107
+ const rows = [];
108
+ for (const item of node) {
109
+ rows.push(columns.map((col) => encode(item[col], keyAlias, opts)));
110
+ }
111
+ return { '~t': { c: encodedColumns, r: rows } };
112
+ }
113
+ return node.map((item) => encode(item, keyAlias, opts));
114
+ }
115
+ if (node && typeof node === 'object') {
116
+ const out = {};
117
+ for (const [key, value] of Object.entries(node)) {
118
+ if (opts.stripNulls && value === null) continue;
119
+ if (
120
+ opts.stripDefaults
121
+ && ['default', 'defaults'].includes(String(key).toLowerCase())
122
+ && isDefaultish(value)
123
+ ) {
124
+ continue;
125
+ }
126
+ const encodedKey = keyAlias[String(key)] || String(key);
127
+ out[encodedKey] = encode(value, keyAlias, opts);
128
+ }
129
+ return out;
130
+ }
131
+ return node;
132
+ }
133
+
134
+ function decode(node, aliasToKey) {
135
+ if (Array.isArray(node)) {
136
+ return node.map((item) => decode(item, aliasToKey));
137
+ }
138
+ if (node && typeof node === 'object') {
139
+ if (node['~t'] && typeof node['~t'] === 'object') {
140
+ const meta = node['~t'];
141
+ const columns = Array.isArray(meta.c) ? meta.c : [];
142
+ const rows = Array.isArray(meta.r) ? meta.r : [];
143
+ const decodedColumns = columns.map((col) => aliasToKey[String(col)] || String(col));
144
+ const items = [];
145
+ for (const row of rows) {
146
+ if (!Array.isArray(row)) continue;
147
+ const item = {};
148
+ for (let i = 0; i < decodedColumns.length; i++) {
149
+ if (i < row.length) {
150
+ item[decodedColumns[i]] = decode(row[i], aliasToKey);
151
+ }
152
+ }
153
+ items.push(item);
154
+ }
155
+ return items;
156
+ }
157
+ const out = {};
158
+ for (const [key, value] of Object.entries(node)) {
159
+ const decodedKey = aliasToKey[String(key)] || String(key);
160
+ out[decodedKey] = decode(value, aliasToKey);
161
+ }
162
+ return out;
163
+ }
164
+ return node;
165
+ }
166
+
167
+ function keyRef(aliasToKey) {
168
+ const digest = createHash('sha256').update(stableJson(aliasToKey), 'utf-8').digest('hex').slice(0, 12);
169
+ return `kdict-${digest}`;
170
+ }
171
+
172
+ export function compressResult(
173
+ inputData,
174
+ options = null,
175
+ {
176
+ keyRegistry = null,
177
+ registryCounter = null,
178
+ reuseKeys = false,
179
+ keyBootstrapInterval = 8,
180
+ } = {}
181
+ ) {
182
+ const opts = makeCompressionOptions(options || {});
183
+ const originalBytes = jsonSize(inputData);
184
+
185
+ if (originalBytes < opts.minPayloadBytes) {
186
+ return {
187
+ encoding: 'lapc-json-v1',
188
+ compressed: false,
189
+ originalBytes,
190
+ compressedBytes: originalBytes,
191
+ savedBytes: 0,
192
+ savedRatio: 0,
193
+ data: inputData,
194
+ keys: {},
195
+ };
196
+ }
197
+
198
+ const keyCounter = {};
199
+ collectKeyFrequency(inputData, keyCounter);
200
+ const keyAlias = buildKeyAliases(keyCounter, opts.mode);
201
+ const encoded = encode(inputData, keyAlias, opts);
202
+ const aliasToKey = {};
203
+ for (const [key, alias] of Object.entries(keyAlias)) {
204
+ aliasToKey[alias] = key;
205
+ }
206
+
207
+ const envelope = {
208
+ encoding: 'lapc-json-v1',
209
+ compressed: true,
210
+ mode: opts.mode,
211
+ originalBytes,
212
+ data: encoded,
213
+ keys: aliasToKey,
214
+ };
215
+
216
+ if (reuseKeys && keyRegistry && typeof keyRegistry === 'object') {
217
+ const ref = keyRef(aliasToKey);
218
+ let includeKeys = true;
219
+ const previous = keyRegistry[ref];
220
+ if (previous && JSON.stringify(previous) === JSON.stringify(aliasToKey)) {
221
+ includeKeys = false;
222
+ if (registryCounter && typeof registryCounter === 'object') {
223
+ const count = (registryCounter[ref] || 0) + 1;
224
+ registryCounter[ref] = count;
225
+ if (keyBootstrapInterval > 0 && count % keyBootstrapInterval === 0) {
226
+ includeKeys = true;
227
+ }
228
+ }
229
+ } else {
230
+ keyRegistry[ref] = cloneJson(aliasToKey);
231
+ if (registryCounter && typeof registryCounter === 'object') {
232
+ registryCounter[ref] = 1;
233
+ }
234
+ }
235
+ envelope.keysRef = ref;
236
+ if (!includeKeys) {
237
+ delete envelope.keys;
238
+ }
239
+ }
240
+
241
+ const compressedBytes = jsonSize(envelope);
242
+ const savedBytes = originalBytes - compressedBytes;
243
+ envelope.compressedBytes = compressedBytes;
244
+ envelope.savedBytes = savedBytes;
245
+ envelope.savedRatio = originalBytes > 0 ? savedBytes / originalBytes : 0;
246
+
247
+ if (savedBytes <= 0) {
248
+ envelope.compressed = false;
249
+ envelope.data = inputData;
250
+ envelope.keys = {};
251
+ delete envelope.keysRef;
252
+ envelope.compressedBytes = originalBytes;
253
+ envelope.savedBytes = 0;
254
+ envelope.savedRatio = 0;
255
+ }
256
+
257
+ return envelope;
258
+ }
259
+
260
+ export function decompressResult(envelope, { keyRegistry = null } = {}) {
261
+ if (!envelope || typeof envelope !== 'object' || envelope.encoding !== 'lapc-json-v1') {
262
+ throw new Error('Unsupported compression envelope');
263
+ }
264
+ if (!envelope.compressed) {
265
+ return envelope.data;
266
+ }
267
+ let keys = envelope.keys;
268
+ if ((!keys || typeof keys !== 'object') && typeof envelope.keysRef === 'string' && keyRegistry) {
269
+ keys = keyRegistry[envelope.keysRef];
270
+ }
271
+ if (!keys || typeof keys !== 'object') {
272
+ throw new Error('Invalid or missing key dictionary in envelope');
273
+ }
274
+ return decode(envelope.data, keys);
275
+ }
276
+
277
+ export function tokenSavings(original, candidate, counter = null) {
278
+ const tc = counter || new TokenCounter();
279
+ return tc.count(original) - tc.count(candidate);
280
+ }
281
+
282
+ export function estimateCompressibility(value) {
283
+ const keyCounter = {};
284
+ const scalarCounter = {};
285
+ let homogeneousLists = 0;
286
+ let totalLists = 0;
287
+
288
+ const walk = (node) => {
289
+ if (Array.isArray(node)) {
290
+ totalLists += 1;
291
+ if (node.length > 0 && node.every((item) => item && typeof item === 'object' && !Array.isArray(item))) {
292
+ const keysets = node.map((item) => JSON.stringify(Object.keys(item).sort()));
293
+ if (new Set(keysets).size === 1) {
294
+ homogeneousLists += 1;
295
+ }
296
+ }
297
+ for (const child of node) walk(child);
298
+ return;
299
+ }
300
+
301
+ if (node && typeof node === 'object') {
302
+ for (const [key, child] of Object.entries(node)) {
303
+ const name = String(key);
304
+ keyCounter[name] = (keyCounter[name] || 0) + 1;
305
+ walk(child);
306
+ }
307
+ return;
308
+ }
309
+
310
+ if (typeof node === 'string' || typeof node === 'number' || typeof node === 'boolean' || node === null) {
311
+ const marker = JSON.stringify(node);
312
+ scalarCounter[marker] = (scalarCounter[marker] || 0) + 1;
313
+ }
314
+ };
315
+
316
+ walk(value);
317
+
318
+ const totalKeys = Object.values(keyCounter).reduce((acc, n) => acc + n, 0);
319
+ const duplicateKeys = Math.max(0, totalKeys - Object.keys(keyCounter).length);
320
+ const keyRepeatRatio = totalKeys > 0 ? duplicateKeys / totalKeys : 0;
321
+
322
+ const totalScalars = Object.values(scalarCounter).reduce((acc, n) => acc + n, 0);
323
+ const duplicateScalars = Math.max(0, totalScalars - Object.keys(scalarCounter).length);
324
+ const scalarRepeatRatio = totalScalars > 0 ? duplicateScalars / totalScalars : 0;
325
+
326
+ const homogeneousRatio = totalLists > 0 ? homogeneousLists / totalLists : 0;
327
+ const score = 0.5 * keyRepeatRatio + 0.25 * scalarRepeatRatio + 0.25 * homogeneousRatio;
328
+ if (score < 0) return 0;
329
+ if (score > 1) return 1;
330
+ return score;
331
+ }
332
+
package/src/state.mjs ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Session/cache/tool-index state for Ultra Lean MCP Proxy.
3
+ */
4
+
5
+ import { createHash } from 'node:crypto';
6
+
7
+ function isPlainObject(value) {
8
+ return Object.prototype.toString.call(value) === '[object Object]';
9
+ }
10
+
11
+ export function canonicalize(value) {
12
+ if (Array.isArray(value)) {
13
+ return value.map((item) => canonicalize(item));
14
+ }
15
+ if (isPlainObject(value)) {
16
+ const out = {};
17
+ const keys = Object.keys(value).sort();
18
+ for (const key of keys) {
19
+ out[key] = canonicalize(value[key]);
20
+ }
21
+ return out;
22
+ }
23
+ return value;
24
+ }
25
+
26
+ export function cloneJson(value) {
27
+ if (value === undefined) return undefined;
28
+ return JSON.parse(JSON.stringify(value));
29
+ }
30
+
31
+ export function stableJsonStringify(value) {
32
+ return JSON.stringify(canonicalize(value));
33
+ }
34
+
35
+ export function stableHash(value) {
36
+ return createHash('sha256').update(stableJsonStringify(value), 'utf-8').digest('hex');
37
+ }
38
+
39
+ export function argsHash(argumentsValue) {
40
+ if (argumentsValue === null || argumentsValue === undefined) {
41
+ return stableHash({});
42
+ }
43
+ return stableHash(argumentsValue);
44
+ }
45
+
46
+ const MUTATING_VERBS = [
47
+ 'create',
48
+ 'update',
49
+ 'delete',
50
+ 'remove',
51
+ 'set',
52
+ 'write',
53
+ 'insert',
54
+ 'patch',
55
+ 'post',
56
+ 'put',
57
+ 'merge',
58
+ 'upload',
59
+ 'commit',
60
+ 'navigate',
61
+ 'open',
62
+ 'close',
63
+ 'click',
64
+ 'type',
65
+ 'press',
66
+ 'select',
67
+ 'hover',
68
+ 'drag',
69
+ 'drop',
70
+ 'scroll',
71
+ 'evaluate',
72
+ 'execute',
73
+ 'goto',
74
+ 'reload',
75
+ 'back',
76
+ 'forward',
77
+ ];
78
+
79
+ export function isMutatingToolName(toolName) {
80
+ const name = String(toolName || '').toLowerCase();
81
+ return MUTATING_VERBS.some((verb) => name.includes(verb));
82
+ }
83
+
84
+ export function makeCacheKey(sessionId, serverName, toolName, argumentsValue) {
85
+ return `${sessionId}:${serverName}:${toolName}:${argsHash(argumentsValue)}`;
86
+ }
87
+
88
+ function nowSeconds() {
89
+ return Date.now() / 1000;
90
+ }
91
+
92
+ function toolSearchTerms(query) {
93
+ return String(query || '')
94
+ .toLowerCase()
95
+ .match(/[a-zA-Z0-9_]+/g) || [];
96
+ }
97
+
98
+ export class ProxyState {
99
+ constructor(maxCacheEntries = 5000) {
100
+ this.maxCacheEntries = Math.max(1, Number(maxCacheEntries) || 5000);
101
+ this._cache = new Map(); // key -> {value, expiresAt, createdAt, hits}
102
+ this._history = new Map(); // key -> json
103
+ this._tools = [];
104
+ this._toolsHash = new Map(); // key -> {lastHash, conditionalHits, updatedAt}
105
+ }
106
+
107
+ cacheGet(key) {
108
+ const entry = this._cache.get(key);
109
+ if (!entry) return null;
110
+ if (entry.expiresAt < nowSeconds()) {
111
+ this._cache.delete(key);
112
+ return null;
113
+ }
114
+ entry.hits += 1;
115
+ return cloneJson(entry.value);
116
+ }
117
+
118
+ cacheSet(key, value, ttlSeconds) {
119
+ const now = nowSeconds();
120
+ this._cache.set(key, {
121
+ value: cloneJson(value),
122
+ createdAt: now,
123
+ expiresAt: now + Math.max(0, Number(ttlSeconds) || 0),
124
+ hits: 0,
125
+ });
126
+ this._evictCacheIfNeeded();
127
+ }
128
+
129
+ cacheInvalidatePrefix(prefix) {
130
+ let removed = 0;
131
+ for (const key of this._cache.keys()) {
132
+ if (key.startsWith(prefix)) {
133
+ this._cache.delete(key);
134
+ removed += 1;
135
+ }
136
+ }
137
+ return removed;
138
+ }
139
+
140
+ _evictCacheIfNeeded() {
141
+ if (this._cache.size <= this.maxCacheEntries) return;
142
+ const ordered = Array.from(this._cache.entries()).sort((a, b) => {
143
+ const ah = a[1].hits - b[1].hits;
144
+ if (ah !== 0) return ah;
145
+ return a[1].createdAt - b[1].createdAt;
146
+ });
147
+ const overflow = this._cache.size - this.maxCacheEntries;
148
+ for (let i = 0; i < overflow; i++) {
149
+ this._cache.delete(ordered[i][0]);
150
+ }
151
+ }
152
+
153
+ historyGet(key) {
154
+ if (!this._history.has(key)) return null;
155
+ return cloneJson(this._history.get(key));
156
+ }
157
+
158
+ historySet(key, value) {
159
+ this._history.set(key, cloneJson(value));
160
+ if (this._history.size > this.maxCacheEntries * 2) {
161
+ const firstKey = this._history.keys().next().value;
162
+ if (firstKey !== undefined) {
163
+ this._history.delete(firstKey);
164
+ }
165
+ }
166
+ }
167
+
168
+ historyInvalidatePrefix(prefix) {
169
+ let removed = 0;
170
+ for (const key of this._history.keys()) {
171
+ if (key.startsWith(prefix)) {
172
+ this._history.delete(key);
173
+ removed += 1;
174
+ }
175
+ }
176
+ return removed;
177
+ }
178
+
179
+ setTools(tools) {
180
+ this._tools = cloneJson(Array.isArray(tools) ? tools : []);
181
+ }
182
+
183
+ getTools() {
184
+ return cloneJson(this._tools);
185
+ }
186
+
187
+ searchTools(query, topK = 8, includeSchemas = true) {
188
+ if (!Array.isArray(this._tools) || this._tools.length === 0) {
189
+ return [];
190
+ }
191
+
192
+ const terms = toolSearchTerms(query);
193
+ const queryLower = String(query || '').toLowerCase();
194
+ const ranked = [];
195
+
196
+ for (const tool of this._tools) {
197
+ if (!isPlainObject(tool)) continue;
198
+ const name = String(tool.name || '');
199
+ const desc = String(tool.description || '');
200
+ const schema = (isPlainObject(tool.inputSchema) && tool.inputSchema)
201
+ || (isPlainObject(tool.input_schema) && tool.input_schema)
202
+ || {};
203
+ const properties = isPlainObject(schema.properties) ? schema.properties : {};
204
+ const paramText = Object.keys(properties).join(' ');
205
+ const haystack = `${name} ${desc} ${paramText}`.toLowerCase();
206
+
207
+ let score = 0;
208
+ if (queryLower && name.toLowerCase().includes(queryLower)) score += 4;
209
+ for (const term of terms) {
210
+ if (name.toLowerCase().includes(term)) score += 2;
211
+ if (desc.toLowerCase().includes(term)) score += 1;
212
+ if (paramText.toLowerCase().includes(term)) score += 1.25;
213
+ if (haystack.includes(term)) score += 0.2;
214
+ }
215
+ if (score <= 0) continue;
216
+ ranked.push([score, tool]);
217
+ }
218
+
219
+ const fallbackRanked = ranked.length > 0
220
+ ? ranked
221
+ : this._tools.map((tool) => [0.01, tool]);
222
+
223
+ fallbackRanked.sort((a, b) => b[0] - a[0]);
224
+ const limit = Math.max(1, Number(topK) || 8);
225
+ const out = [];
226
+ for (const [score, tool] of fallbackRanked.slice(0, limit)) {
227
+ const item = {
228
+ name: tool.name,
229
+ score: Number(score.toFixed(3)),
230
+ description: tool.description || '',
231
+ };
232
+ if (includeSchemas) {
233
+ const schema = tool.inputSchema || tool.input_schema;
234
+ if (schema !== undefined) {
235
+ item.inputSchema = cloneJson(schema);
236
+ }
237
+ }
238
+ out.push(item);
239
+ }
240
+ return out;
241
+ }
242
+
243
+ toolsHashGet(key) {
244
+ const entry = this._toolsHash.get(key);
245
+ if (!entry) return null;
246
+ return {
247
+ lastHash: entry.lastHash ?? null,
248
+ conditionalHits: Number(entry.conditionalHits || 0),
249
+ updatedAt: Number(entry.updatedAt || 0),
250
+ };
251
+ }
252
+
253
+ toolsHashSetLast(key, toolsHash) {
254
+ const now = nowSeconds();
255
+ const current = this._toolsHash.get(key) || {
256
+ lastHash: null,
257
+ conditionalHits: 0,
258
+ updatedAt: 0,
259
+ };
260
+ if (current.lastHash !== toolsHash) {
261
+ current.conditionalHits = 0;
262
+ }
263
+ current.lastHash = toolsHash;
264
+ current.updatedAt = now;
265
+ this._toolsHash.set(key, current);
266
+ }
267
+
268
+ toolsHashRecordHit(key) {
269
+ const now = nowSeconds();
270
+ const current = this._toolsHash.get(key) || {
271
+ lastHash: null,
272
+ conditionalHits: 0,
273
+ updatedAt: 0,
274
+ };
275
+ current.conditionalHits += 1;
276
+ current.updatedAt = now;
277
+ this._toolsHash.set(key, current);
278
+ return current.conditionalHits;
279
+ }
280
+
281
+ toolsHashResetHits(key) {
282
+ const now = nowSeconds();
283
+ const current = this._toolsHash.get(key) || {
284
+ lastHash: null,
285
+ conditionalHits: 0,
286
+ updatedAt: 0,
287
+ };
288
+ current.conditionalHits = 0;
289
+ current.updatedAt = now;
290
+ this._toolsHash.set(key, current);
291
+ }
292
+ }
293
+
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Helpers for tools_hash_sync MCP extension.
3
+ */
4
+
5
+ import { createHash } from 'node:crypto';
6
+ import { canonicalize } from './state.mjs';
7
+
8
+ const TOOLS_HASH_WIRE_RE = /^([a-z0-9_]+):([0-9a-f]{64})$/;
9
+
10
+ export function canonicalToolsJson(toolsPayload) {
11
+ return JSON.stringify(canonicalize(toolsPayload));
12
+ }
13
+
14
+ export function computeToolsHash(
15
+ toolsPayload,
16
+ {
17
+ algorithm = 'sha256',
18
+ includeServerFingerprint = false,
19
+ serverFingerprint = null,
20
+ } = {}
21
+ ) {
22
+ if (algorithm !== 'sha256') {
23
+ throw new Error(`Unsupported tools hash algorithm: ${algorithm}`);
24
+ }
25
+
26
+ const payload = canonicalize(toolsPayload);
27
+ let preimage = payload;
28
+ if (includeServerFingerprint) {
29
+ preimage = {
30
+ tools: payload,
31
+ server_fingerprint: serverFingerprint || '',
32
+ };
33
+ }
34
+ const digest = createHash('sha256').update(JSON.stringify(preimage), 'utf-8').digest('hex');
35
+ return `sha256:${digest}`;
36
+ }
37
+
38
+ export function parseIfNoneMatch(value, { expectedAlgorithm = 'sha256' } = {}) {
39
+ if (typeof value !== 'string') {
40
+ return null;
41
+ }
42
+ const candidate = value.trim().toLowerCase();
43
+ const match = candidate.match(TOOLS_HASH_WIRE_RE);
44
+ if (!match) {
45
+ return null;
46
+ }
47
+ if (match[1] !== expectedAlgorithm) {
48
+ return null;
49
+ }
50
+ return candidate;
51
+ }
52
+